/** * GGraphs is a versatile class for rendering various types of SVG-based graphs, including line, bar, pie, donut, and gauge charts. * For more information, visit {@link https://www.kotchasan.com/}. * * @file GGraphs class implementation. * @fileoverview This file contains the implementation of the GGraphs class. * @link https://www.kotchasan.com/ * @copyright 2024 Goragod.com * @license https://www.kotchasan.com/license/ */ class GGraphs { /** * Creates an instance of GGraphs. * @param {string} containerId - The ID of the container element where the graph will be rendered. * @param {Object} [options={}] - Configuration options for the graph. */ constructor(containerId, options = {}) { this.container = document.getElementById(containerId); if (!this.container) { throw new Error(`Container with ID "${containerId}" not found.`); } this.width = this.container.clientWidth; this.height = this.container.clientHeight; this.createSVG(); const containerStyles = window.getComputedStyle(this.container); const defaultFontSize = parseInt(containerStyles.fontSize, 10); const defaultFontFamily = containerStyles.fontFamily; const defaultTextColor = containerStyles.color; const defaultBackgroundColor = containerStyles.backgroundColor; this.options = { colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F9ED69', '#F08A5D', '#B83B5E', '#6A2C70', '#00B8A9', '#F8F3D4', '#3F72AF'], backgroundColor: defaultBackgroundColor, showGrid: true, gridColor: '#E0E0E0', axisColor: '#333333', curveType: 'linear', maxGaugeValue: 100, centerText: null, showCenterText: true, gap: 2, borderWidth: 1, borderColor: '#000000', pointRadius: 4, lineWidth: 2, fillArea: false, fillOpacity: 0.1, fontFamily: defaultFontFamily, textColor: defaultTextColor, fontSize: defaultFontSize, showAxisLabels: true, showAxis: true, animationDuration: 1000, donutThickness: 50, gaugeCurveWidth: 20, showLegend: true, legendPosition: 'bottom', showTooltip: true, tooltipFormatter: null, showDataLabels: true, animation: true, maxDataPoints: 20, type: 'line', table: null, data: null, onClick: null, ...options }; this.validateOptions(this.options); this.data = []; this.minValue = 0; this.maxValue = 0; this.currentChartType = this.options.type; this.legend = null; this.calculateFontSize(); this.setMargins(); this.visibleDataCount = this.calculateVisibleDataCount(); if (this.options.table) { this.initialize(); } else if (this.options.data) { this.setData(this.options.data); this.renderGraph(); } this.handleResize = this.debounce(this.handleResize.bind(this), 200); window.addEventListener('resize', this.handleResize); } /** * Validates the provided options object. * @param {Object} options - The options to validate. */ validateOptions(options) { if (options.colors && !Array.isArray(options.colors)) { throw new TypeError('Option "colors" must be an array.'); } if (typeof options.showGrid !== 'boolean') { throw new TypeError('Option "showGrid" must be a boolean.'); } if (typeof options.legendPosition !== 'string') { throw new TypeError('Option "legendPosition" must be a string.'); } if (typeof options.maxGaugeValue !== 'number') { throw new TypeError('Option "maxGaugeValue" must be a number.'); } // Add more validations as needed } /** * Creates an SVG element and appends it to the container. */ createSVG() { this.svg = this.createSVGElement('svg', { width: '100%', height: '100%', viewBox: `0 0 ${this.width} ${this.height}`, role: 'img', 'aria-label': 'SVG Data Graph' }); this.container.appendChild(this.svg); } /** * Creates an SVG element with the specified attributes. * @param {string} type - The type of SVG element to create. * @param {Object} [attributes={}] - The attributes to set on the SVG element. * @returns {SVGElement} The created SVG element. */ createSVGElement(type, attributes = {}) { const elem = document.createElementNS('http://www.w3.org/2000/svg', type); Object.keys(attributes).forEach(attr => elem.setAttribute(attr, attributes[attr])); return elem; } /** * Clears the existing SVG content and creates a new SVG element. */ clear() { if (this.svg && this.container.contains(this.svg)) { this.container.removeChild(this.svg); } this.createSVG(); } /** * Creates a debounced version of the provided function. * @param {Function} func - The function to debounce. * @param {number} wait - The debounce interval in milliseconds. * @returns {Function} The debounced function. */ debounce(func, wait) { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } /** * Calculates and sets the font size based on the container dimensions. */ calculateFontSize() { const minDimension = Math.min(this.width, this.height); this.options.fontSize = Math.max(10, Math.min(this.options.fontSize, minDimension / 20)); this.options.labelFontSize = this.options.fontSize * 0.8; } /** * Calculates the number of visible data points based on the configuration. * @returns {number} The number of visible data points. */ calculateVisibleDataCount() { if (!this.data || this.data.length === 0 || !this.data[0].data) { return 0; } if (this.options.maxDataPoints === 0) { return this.data[0].data.length; } return Math.min(this.options.maxDataPoints, this.data[0].data.length); } /** * Handles window resize events by updating dimensions and redrawing the graph. */ handleResize() { this.width = this.container.clientWidth; this.height = this.container.clientHeight; this.svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); this.calculateFontSize(); this.setMargins(); this.visibleDataCount = this.calculateVisibleDataCount(); this.redrawGraph(); } /** * Initializes the graph by loading data from a table or the provided data array. */ initialize() { this.clear(); this.calculateFontSize(); this.setMargins(); if (this.options.table) { const table = document.getElementById(this.options.table); if (table) { const tableData = this.loadFromTable(table); const seriesData = this.processTableData(tableData); this.setData(seriesData); } else { console.warn(`Table with ID "${this.options.table}" not found.`); } } else if (this.options.data) { this.setData(this.options.data); } this.renderGraph(); } /** * Loads raw data from an HTML table. * @param {HTMLTableElement} table - The table element to load data from. * @returns {Object} The raw table data. */ loadFromTable(table) { const rawData = { headers: { title: '', items: [] }, rows: [] }; const headerCells = table.querySelectorAll('thead > tr:first-child > th'); headerCells.forEach((cell, index) => { if (index === 0) { rawData.headers.title = cell.textContent.trim(); } else { rawData.headers.items.push({ text: cell.textContent.trim(), href: cell.querySelector('a') ? cell.querySelector('a').getAttribute('href') : null, target: cell.querySelector('a') ? cell.querySelector('a').getAttribute('target') : null }); } }); const bodyRows = table.querySelectorAll('tbody > tr'); bodyRows.forEach(tr => { const row = { title: '', items: [] }; const cells = tr.querySelectorAll('th, td'); cells.forEach((cell, index) => { if (cell.tagName === 'TH') { row.title = cell.textContent.trim(); } else { const value = cell.dataset.value ? parseFloat(cell.dataset.value) : parseFloat(cell.textContent.replace(/,/g, '')); const tooltip = cell.dataset.tooltip ? cell.dataset.tooltip : `${rawData.headers.title} ${rawData.headers.items[index - 1].text} ${row.title} ${value}`; row.items.push({ value: value, tooltip: tooltip, label: row.title, color: cell.dataset.color || null }); } }); rawData.rows.push(row); }); return rawData; } /** * Processes raw table data into a series data format suitable for the graph. * @param {Object} rawData - The raw table data. * @returns {Array} The processed series data. */ processTableData(rawData) { const processedData = []; rawData.headers.items.forEach((header, seriesIndex) => { const series = { name: header.text, color: this.options.colors[seriesIndex % this.options.colors.length], data: [] }; rawData.rows.forEach(row => { const point = row.items[seriesIndex]; if (point) { series.data.push({ label: row.title, value: point.value, tooltip: point.tooltip, color: point.color }); } }); processedData.push(series); }); return processedData; } /** * Sets the data for the graph and updates the range. * @param {Array} data - The data to set. */ setData(data) { if (!Array.isArray(data)) { throw new Error('Data must be an array of series.'); } this.data = data; const allValues = data.flatMap(series => series.data.map(point => point.value)); this.minValue = Math.min(...allValues); this.maxValue = Math.max(...allValues); this.calculateNiceRange(); this.visibleDataCount = this.calculateVisibleDataCount(); this.renderGraph(); } /** * Calculates a "nice" range for the y-axis based on the data. */ calculateNiceRange() { const range = this.maxValue - this.minValue; if (range === 0) { this.minNice = this.minValue - 1; this.maxNice = this.maxValue + 1; return; } const roughStep = range / 5; const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); const niceStep = Math.ceil(roughStep / magnitude) * magnitude; this.minNice = Math.floor(this.minValue / niceStep) * niceStep; this.maxNice = Math.ceil(this.maxValue / niceStep) * niceStep; if (this.minValue > 0) { if (this.minValue === this.minNice) { this.minNice = Math.max(0, this.minNice - niceStep); } if (this.maxValue === this.maxNice) { this.maxNice += niceStep; } } if (this.maxValue < 0) { if (this.maxValue === this.maxNice) { this.maxNice = Math.min(0, this.maxNice + niceStep); } if (this.minValue === this.minNice) { this.minNice -= niceStep; } } } /** * Draws the axes on the graph. */ drawAxes() { const axesGroup = this.createSVGElement('g', {class: 'axes'}); let yBase = 0; if (this.minNice > 0) { yBase = this.minNice; } else if (this.maxNice < 0) { yBase = this.maxNice; } const xAxis = this.createSVGElement('line', { x1: this.margin.left, y1: this.getPointY(yBase), x2: this.width - this.margin.right, y2: this.getPointY(yBase), stroke: this.options.axisColor, 'stroke-width': '2' }); axesGroup.appendChild(xAxis); const yAxis = this.createSVGElement('line', { x1: this.margin.left, y1: this.margin.top, x2: this.margin.left, y2: this.height - this.margin.bottom, stroke: this.options.axisColor, 'stroke-width': '2' }); axesGroup.appendChild(yAxis); if (this.options.showAxisLabels) { this.drawYAxisLabels(axesGroup); } this.svg.appendChild(axesGroup); } /** * Draws the Y-axis labels. * @param {SVGElement} axesGroup - The group element to append the labels to. */ drawYAxisLabels(axesGroup) { const steps = 5; for (let i = 0; i <= steps; i++) { const value = this.minNice + (i / steps) * (this.maxNice - this.minNice); const y = this.getPointY(value); const label = this.createSVGElement('text', { x: this.margin.left - 10, y: y, 'text-anchor': 'end', 'alignment-baseline': 'middle', 'font-size': this.options.labelFontSize, 'font-family': this.options.fontFamily, fill: this.options.textColor }); label.textContent = this.formatValue(value); axesGroup.appendChild(label); } } /** * Draws vertical grid lines at the specified x positions. * @param {Array} xPositions - The x positions for the grid lines. */ drawVerticalGridLines(xPositions) { const gridGroup = this.createSVGElement('g', {class: 'vertical-grid'}); xPositions.forEach(x => { const line = this.createSVGElement('line', { x1: x, y1: this.margin.top, x2: x, y2: this.height - this.margin.bottom, stroke: this.options.gridColor, 'stroke-dasharray': '5,5' }); gridGroup.appendChild(line); }); this.svg.appendChild(gridGroup); } /** * Draws a horizontal grid line at the specified y position. * @param {number} y - The y position for the grid line. */ drawHorizontalGridLines(y) { const gridLine = this.createSVGElement('line', { x1: this.margin.left, y1: y, x2: this.width - this.margin.right, y2: y, stroke: this.options.gridColor, 'stroke-width': '1', 'stroke-dasharray': '5,5' }); this.svg.appendChild(gridLine); } /** * Draws a label at the specified position with optional rotation. * @param {number} x - The x position of the label. * @param {number} y - The y position of the label. * @param {string} text - The text content of the label. * @param {boolean} rotate - Whether to rotate the label by 45 degrees. */ drawLabel(x, y, text, rotate) { const label = this.createSVGElement('text', { x: x, y: y, 'text-anchor': 'middle', 'font-size': this.options.labelFontSize, 'font-family': this.options.fontFamily, fill: this.options.textColor }); label.textContent = text; if (rotate) { label.setAttribute('transform', `rotate(45, ${x}, ${y})`); } this.svg.appendChild(label); } /** * Adds an animation to an SVG element. * @param {SVGElement} element - The SVG element to animate. * @param {Object} attributes - The attributes to animate. */ addAnimation(element, attributes) { if (this.options.animation) { const animate = this.createSVGElement('animate'); for (const [key, value] of Object.entries(attributes)) { animate.setAttribute(key, value); } element.appendChild(animate); } } /** * Renders the graph based on the current chart type. * @param {boolean} [animation=this.options.animation] - Whether to animate the rendering. */ renderGraph(animation = this.options.animation) { try { const previousAnimation = this.options.animation; this.options.animation = animation; this.clear(); this.setMargins(); switch (this.currentChartType) { case 'line': this.drawLineGraph(); break; case 'bar': this.drawBarGraph(); break; case 'pie': this.drawPieChart(false); break; case 'donut': this.drawPieChart(true); break; case 'gauge': this.drawGauge(); break; default: throw new Error(`Unknown chart type: ${this.currentChartType}`); } if (this.options.showLegend) { this.drawLegend(); } this.options.animation = previousAnimation; } catch (error) { console.error('Error in renderGraph():', error); } } /** * Redraws the graph without animation. * @param {boolean} [animation=this.options.animation] - Whether to animate the redrawing. */ redrawGraph(animation = this.options.animation) { this.renderGraph(animation); } /** * Calculates the x-coordinate for a data point based on its index. * @param {number} index - The index of the data point. * @returns {number} The x-coordinate. */ getPointX(index) { if (this.visibleDataCount <= 1) { return this.margin.left + (this.width - this.margin.left - this.margin.right) / 2; } const availableWidth = this.width - this.margin.left - this.margin.right; return this.margin.left + (index / (this.visibleDataCount - 1)) * availableWidth; } /** * Calculates the y-coordinate for a data value. * @param {number} value - The data value. * @returns {number} The y-coordinate. */ getPointY(value) { const availableHeight = this.height - this.margin.top - this.margin.bottom; if (this.maxNice === this.minNice) { return this.margin.top + availableHeight / 2; } return this.margin.top + ((this.maxNice - value) / (this.maxNice - this.minNice)) * availableHeight; } /** * Generates a linear path string for a series of data points. * @param {Array} data - The data points. * @returns {string} The path string. */ getLinearPath(data) { return data.map((point, index) => `${index === 0 ? 'M' : 'L'}${this.getPointX(index)},${this.getPointY(point.value)}` ).join(' '); } /** * Generates a curved path string for a series of data points. * @param {Array} data - The data points. * @returns {string} The curved path string. */ getCurvePath(data) { if (data.length === 0) return ''; let path = `M${this.getPointX(0)},${this.getPointY(data[0].value)}`; for (let i = 1; i < data.length; i++) { const x1 = this.getPointX(i - 1); const y1 = this.getPointY(data[i - 1].value); const x2 = this.getPointX(i); const y2 = this.getPointY(data[i].value); const controlX1 = x1 + (x2 - x1) / 3; const controlX2 = x2 - (x2 - x1) / 3; path += ` C${controlX1},${y1} ${controlX2},${y2} ${x2},${y2}`; } return path; } /** * Describes an arc path. * @param {number} x - The x-coordinate of the center. * @param {number} y - The y-coordinate of the center. * @param {number} radius - The radius of the arc. * @param {number} startAngle - The start angle in radians. * @param {number} endAngle - The end angle in radians. * @returns {string} The SVG path data for the arc. */ describeArc(x, y, radius, startAngle, endAngle) { const start = this.polarToCartesian(x, y, radius, endAngle); const end = this.polarToCartesian(x, y, radius, startAngle); const largeArcFlag = endAngle - startAngle <= Math.PI ? "0" : "1"; return [ "M", start.x, start.y, "A", radius, radius, 0, largeArcFlag, 0, end.x, end.y ].join(" "); } /** * Converts polar coordinates to Cartesian coordinates. * @param {number} centerX - The x-coordinate of the center. * @param {number} centerY - The y-coordinate of the center. * @param {number} radius - The radius. * @param {number} angleInRadians - The angle in radians. * @returns {Object} The Cartesian coordinates. */ polarToCartesian(centerX, centerY, radius, angleInRadians) { return { x: centerX + (radius * Math.cos(angleInRadians)), y: centerY + (radius * Math.sin(angleInRadians)) }; } /** * Adds multiple data points to the graph. * @param {Array} newDataPoints - The new data points to add. */ addDataPoints(newDataPoints) { newDataPoints.forEach(({seriesIndex, data}) => { if (seriesIndex >= this.data.length) { console.error(`Series index ${seriesIndex} out of range.`); return; } this.data[seriesIndex].data.push(data); if (this.options.maxDataPoints !== 0 && this.data[seriesIndex].data.length > this.options.maxDataPoints) { const removed = this.data[seriesIndex].data.shift(); if (removed.value === this.minValue || removed.value === this.maxValue) { const allValues = this.data.flatMap(series => series.data.map(point => point.value)); this.minValue = Math.min(...allValues); this.maxValue = Math.max(...allValues); } } else { this.minValue = Math.min(this.minValue, data.value); this.maxValue = Math.max(this.maxValue, data.value); } }); this.calculateNiceRange(); this.visibleDataCount = this.calculateVisibleDataCount(); this.redrawGraph(false); } /** * Adds a single data point to a specific series. * @param {Object} newData - The new data point to add. * @param {number} [seriesIndex=0] - The index of the series to add the data point to. */ addDataPoint(newData, seriesIndex = 0) { if (seriesIndex >= this.data.length) { console.error('Series index out of range.'); return; } this.data[seriesIndex].data.push(newData); if (this.options.maxDataPoints !== 0 && this.data[seriesIndex].data.length > this.options.maxDataPoints) { const removed = this.data[seriesIndex].data.shift(); if (removed.value === this.minValue || removed.value === this.maxValue) { const allValues = this.data.flatMap(series => series.data.map(point => point.value)); this.minValue = Math.min(...allValues); this.maxValue = Math.max(...allValues); } } else { this.minValue = Math.min(this.minValue, newData.value); this.maxValue = Math.max(this.maxValue, newData.value); } this.calculateNiceRange(); this.visibleDataCount = this.calculateVisibleDataCount(); this.redrawGraph(); } /** * Draws a line graph based on the current data. */ drawLineGraph() { const visibleDataCount = this.visibleDataCount; if (visibleDataCount === 0) { return; } const seriesCount = this.data.length; const margin = this.margin; const availableWidth = this.width - margin.left - margin.right; if (this.options.showGrid) { const steps = 5; for (let i = 0; i <= steps; i++) { const y = this.getPointY(this.minNice + (i / steps) * (this.maxNice - this.minNice)); this.drawHorizontalGridLines(y); } const xPositionsSet = new Set(); for (let i = 0; i < visibleDataCount; i++) { const x = this.getPointX(i); xPositionsSet.add(x); } const xPositions = Array.from(xPositionsSet); this.drawVerticalGridLines(xPositions); } if (this.options.showAxisLabels && seriesCount > 0) { const labels = this.data[0].data.slice(0, this.visibleDataCount).map(point => point.label); const labelText = labels.join(' '); const estimatedWidth = this.estimateTextWidth(labelText); const totalLabelWidth = estimatedWidth + (visibleDataCount * 10); const rotate = availableWidth < totalLabelWidth; labels.forEach((label, i) => { const x = this.getPointX(i); this.drawLabel(x, this.height - this.margin.bottom + 20, label, rotate); }); } if (this.options.showAxis) { this.drawAxes(); } const lineGroup = this.createSVGElement('g', {class: 'lines'}); const clipPathId = `clipPath-${Date.now()}`; const clipPath = this.createSVGElement('clipPath', {id: clipPathId}); if (this.options.animation) { const clipRect = this.createSVGElement('rect', { x: margin.left, y: margin.top, width: '0', height: this.height - margin.top - margin.bottom }); const animateClip = this.createSVGElement('animate', { attributeName: 'width', from: '0', to: availableWidth, dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); clipRect.appendChild(animateClip); clipPath.appendChild(clipRect); this.svg.appendChild(clipPath); } this.data.forEach((series, seriesIndex) => { const color = series.color || this.options.colors[seriesIndex % this.options.colors.length]; const linePath = this.createSVGElement('path', { d: this.options.curveType === 'curve' ? this.getCurvePath(series.data.slice(0, this.visibleDataCount)) : this.getLinearPath(series.data.slice(0, this.visibleDataCount)), stroke: color, fill: 'none', 'stroke-width': this.options.lineWidth }); if (this.options.fillArea) { const fillPath = this.createSVGElement('path', {fill: color, 'fill-opacity': this.options.fillOpacity, 'clip-path': `url(#${clipPathId})`}); const fillY = this.minNice >= 0 ? this.getPointY(this.minNice) : this.maxNice <= 0 ? this.getPointY(this.maxNice) : this.getPointY(0); const finalD = `${linePath.getAttribute('d')} L${this.getPointX(this.visibleDataCount - 1)},${fillY} L${this.getPointX(0)},${fillY} Z`; let initialD = ''; if (this.options.animation) { linePath.setAttribute('clip-path', `url(#${clipPathId})`); initialD = series.data.slice(0, this.visibleDataCount).map((point, index) => `${index === 0 ? 'M' : 'L'}${this.getPointX(index)},${fillY}` ).join(' ') + ' Z'; fillPath.setAttribute('d', initialD); const animateFill = this.createSVGElement('animate', { attributeName: 'd', from: initialD, to: finalD, dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); fillPath.appendChild(animateFill); } else { fillPath.setAttribute('d', finalD); } lineGroup.appendChild(fillPath); } if (this.options.animation) { const length = linePath.getTotalLength(); linePath.setAttribute('stroke-dasharray', length); linePath.setAttribute('stroke-dashoffset', length); const animate = this.createSVGElement('animate', { attributeName: 'stroke-dashoffset', from: length, to: '0', dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); linePath.appendChild(animate); } if (typeof this.options.onClick === 'function') { linePath.style.cursor = 'pointer'; linePath.addEventListener('click', () => { this.options.onClick({ type: 'line', series: series, data: series.data.slice(0, this.visibleDataCount) }); }); } lineGroup.appendChild(linePath); }); this.svg.appendChild(lineGroup); const pointsGroup = this.createSVGElement('g', {class: 'points'}); this.data.forEach((series, seriesIndex) => { const color = series.color || this.options.colors[seriesIndex % this.options.colors.length]; series.data.slice(0, this.visibleDataCount).forEach((point, index) => { const x = this.getPointX(index); const y = this.getPointY(point.value); const verticalLineHeight = -15; const horizontalLineLength = 5; const verticalLineXEnd = x + horizontalLineLength; const verticalLineYEnd = y + verticalLineHeight; const lineVertical = this.createSVGElement('line', { x1: x, y1: y, x2: verticalLineXEnd, y2: verticalLineYEnd, stroke: color, 'stroke-width': '1' }); if (this.options.animation) { const animateVerticalLine = this.createSVGElement('animate', { attributeName: 'y2', from: y, to: verticalLineYEnd, dur: '0.5s', fill: 'freeze' }); lineVertical.appendChild(animateVerticalLine); } pointsGroup.appendChild(lineVertical); const horizontalLineXEnd = verticalLineXEnd + horizontalLineLength; const lineHorizontal = this.createSVGElement('line', { x1: verticalLineXEnd, y1: verticalLineYEnd, y2: verticalLineYEnd, stroke: color, 'stroke-width': '1' }); if (this.options.animation) { lineHorizontal.setAttribute('x2', verticalLineXEnd); const animateHorizontalLine = this.createSVGElement('animate', { attributeName: 'x2', from: verticalLineXEnd, to: horizontalLineXEnd, dur: '0.5s', begin: '0.5s', fill: 'freeze' }); lineHorizontal.appendChild(animateHorizontalLine); } else { lineHorizontal.setAttribute('x2', horizontalLineXEnd); } pointsGroup.appendChild(lineHorizontal); if (this.options.showDataLabels) { const label = this.createSVGElement('text', { x: horizontalLineXEnd, y: verticalLineYEnd, 'text-anchor': horizontalLineXEnd > x ? 'start' : 'end', 'alignment-baseline': 'middle', 'font-size': this.options.labelFontSize, 'font-family': this.options.fontFamily, fill: color }); label.textContent = this.getLabelContent(series, point); if (this.options.animation) { label.setAttribute('opacity', '0'); const animateOpacity = this.createSVGElement('animate', { attributeName: 'opacity', from: '0', to: '1', dur: '0.5s', begin: '0.5s', fill: 'freeze' }); label.appendChild(animateOpacity); const animatePosition = this.createSVGElement('animateTransform', { attributeName: 'transform', type: 'translate', from: `0,0`, to: `${horizontalLineLength},0`, dur: '0.5s', begin: '0.5s', fill: 'freeze' }); label.appendChild(animatePosition); } pointsGroup.appendChild(label); } const circle = this.createSVGElement('circle', { cx: x, cy: y, r: this.options.animation ? '0' : this.options.pointRadius, fill: this.options.backgroundColor, stroke: color, 'stroke-width': 2 }); if (this.options.showTooltip) { const title = this.createSVGElement('title'); title.textContent = this.getTooltipContent(series, point); circle.appendChild(title); circle.setAttribute('cursor', 'pointer'); } if (typeof this.options.onClick === 'function') { circle.style.cursor = 'pointer'; circle.addEventListener('click', () => { this.options.onClick({ type: 'point', series: series, data: point }); }); } if (this.options.animation) { const animateRadius = this.createSVGElement('animate', { attributeName: 'r', from: '0', to: this.options.pointRadius, dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); circle.appendChild(animateRadius); const animateOpacity = this.createSVGElement('animate', { attributeName: 'opacity', from: '0', to: '1', dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); circle.appendChild(animateOpacity); } pointsGroup.appendChild(circle); }); }); this.svg.appendChild(pointsGroup); if (this.options.showCenterText) { const centerText = this.createSVGElement('text', { x: this.width / 2, y: this.height / 2, 'text-anchor': 'middle', 'font-size': this.options.fontSize, 'font-family': this.options.fontFamily, fill: this.options.textColor, 'font-weight': 'bold' }); centerText.textContent = this.options.centerText || ''; this.svg.appendChild(centerText); } } /** * Draws a bar graph based on the current data. */ drawBarGraph() { const visibleDataCount = this.visibleDataCount; if (visibleDataCount === 0) { return; } const seriesCount = this.data.length; const margin = this.margin; const availableWidth = this.width - margin.left - margin.right; const groupWidth = availableWidth / visibleDataCount; const barWidth = groupWidth / (seriesCount + 1); const barGap = (groupWidth - seriesCount * barWidth) / (seriesCount + 1); const zeroY = this.getPointY(0); if (this.options.showGrid) { const steps = 5; for (let i = 0; i <= steps; i++) { const y = this.getPointY(this.minNice + (i / steps) * (this.maxNice - this.minNice)); this.drawHorizontalGridLines(y); } } const xPositions = []; for (let i = 0; i < visibleDataCount; i++) { const x = margin.left + i * groupWidth; xPositions.push(x); } if (this.options.showGrid) { this.drawVerticalGridLines(xPositions.map(x => x + groupWidth)); } if (this.options.showAxisLabels && this.data.length > 0) { const labels = this.data[0].data.slice(0, this.visibleDataCount).map(point => point.label); const labelText = labels.join(' '); const estimatedWidth = this.estimateTextWidth(labelText); const totalLabelWidth = estimatedWidth + (visibleDataCount * 10); const rotate = availableWidth < totalLabelWidth; labels.forEach((label, i) => { const x = xPositions[i] + groupWidth / 2; this.drawLabel(x, this.height - margin.bottom + 20, label, rotate); }); } if (this.options.showAxis) { this.drawAxes(); } this.data.forEach((series, seriesIndex) => { series.data.slice(0, visibleDataCount).forEach((point, index) => { const x = xPositions[index] + barGap + seriesIndex * (barWidth + barGap); let y, height; const yValue = this.getPointY(point.value); if (this.minNice >= 0 && this.maxNice >= 0) { y = yValue; height = this.getPointY(this.minNice) - yValue; } else if (this.minNice <= 0 && this.maxNice <= 0) { y = this.getPointY(this.maxNice); height = yValue - y; } else { y = Math.min(zeroY, yValue); height = Math.abs(zeroY - yValue); } const color = point.color || series.color || this.options.colors[seriesIndex % this.options.colors.length]; const bar = this.createSVGElement('rect', { x: x, width: barWidth, height: this.options.animation ? '0' : height, fill: color }); if (this.options.borderColor) { bar.setAttribute('stroke', this.options.borderColor); bar.setAttribute('stroke-width', this.options.borderWidth); } if (this.options.showTooltip) { const title = this.createSVGElement('title'); title.textContent = this.getTooltipContent(series, point); bar.appendChild(title); bar.setAttribute('cursor', 'pointer'); } if (typeof this.options.onClick === 'function') { bar.style.cursor = 'pointer'; bar.addEventListener('click', () => { this.options.onClick({ type: 'bar', series: series, data: point }); }); } let yForm; if (this.minNice >= 0 && this.maxNice >= 0) { yForm = this.getPointY(this.minNice); } else if (this.minNice < 0 && this.maxNice < 0) { yForm = this.getPointY(this.maxNice); } else { yForm = this.getPointY(0); } if (this.options.animation) { bar.setAttribute('y', y); const animHeight = this.createSVGElement('animate', { attributeName: 'height', from: '0', to: height, dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); bar.appendChild(animHeight); const animY = this.createSVGElement('animate', { attributeName: 'y', from: yForm, to: y, dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); bar.appendChild(animY); } else { bar.setAttribute('y', y); bar.setAttribute('height', height); } this.svg.appendChild(bar); if (this.options.showDataLabels) { const label = this.createSVGElement('text', { x: x + barWidth / 2, 'text-anchor': 'middle', 'font-size': this.options.labelFontSize, 'font-family': this.options.fontFamily, fill: color }); label.textContent = this.getLabelContent(series, point); if (point.value >= 0) { label.setAttribute('y', y - 5); if (this.options.animation) { const animLabelY = this.createSVGElement('animate', { attributeName: 'y', from: yForm - 5, to: y - 5, dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); label.appendChild(animLabelY); } } else { label.setAttribute('y', y + height + 15); if (this.options.animation) { const animLabelY = this.createSVGElement('animate', { attributeName: 'y', from: yForm + 15, to: y + height + 15, dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); label.appendChild(animLabelY); } } this.svg.appendChild(label); } }); }); } /** * Draws a pie or donut chart based on the current data. * @param {boolean} isDonut - Whether to draw a donut chart instead of a pie chart. */ drawPieChart(isDonut = false) { if (!this.data || !this.data.length) return; const radius = this.options.showLegend ? Math.min(this.width - this.margin.left - this.margin.right, this.height - this.margin.top - this.margin.bottom) / 1.6 : Math.min(this.width - this.margin.left - this.margin.right, this.height - this.margin.top - this.margin.bottom) / 2.2; const centerX = this.margin.left + (this.width - this.margin.left - this.margin.right) / 2; const centerY = this.margin.top + (this.height - this.margin.top - this.margin.bottom) / 2; let startAngle = -Math.PI / 2; const total = this.data.reduce((sum, series) => sum + series.data.reduce((s, p) => s + p.value, 0), 0); if (total === 0) { console.warn('Total value of pie chart is 0. Cannot draw pie chart.'); return; } const pieGroup = this.createSVGElement('g', {class: 'pie-group'}); this.data.forEach((series, seriesIndex) => { series.data.forEach((point, index) => { const sliceAngle = (point.value / total) * 2 * Math.PI; const endAngle = startAngle + sliceAngle; const midAngle = startAngle + sliceAngle / 2; const color = point.color || series.color || this.options.colors[index % this.options.colors.length]; const x1 = centerX + radius * Math.cos(startAngle); const y1 = centerY + radius * Math.sin(startAngle); const x2 = centerX + radius * Math.cos(endAngle); const y2 = centerY + radius * Math.sin(endAngle); const largeArcFlag = sliceAngle > Math.PI ? "1" : "0"; const pathData = [ `M ${centerX} ${centerY}`, `L ${x1} ${y1}`, `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`, 'Z' ].join(' '); const slice = this.createSVGElement('path', { d: pathData, fill: color, stroke: this.options.gap > 0 ? this.options.backgroundColor : (this.options.borderWidth > 0 ? (this.options.borderColor || '#000') : null), 'stroke-width': this.options.gap > 0 || this.options.borderWidth > 0 ? this.options.gap > 0 ? this.options.gap : this.options.borderWidth : null }); if (this.options.showTooltip) { const title = this.createSVGElement('title'); title.textContent = this.getTooltipContent(series, point); slice.appendChild(title); slice.setAttribute('cursor', 'pointer'); } if (typeof this.options.onClick === 'function') { slice.style.cursor = 'pointer'; slice.addEventListener('click', () => { this.options.onClick({ type: 'pie', series: series, data: point }); }); } if (this.options.animation) { const pathLength = slice.getTotalLength(); slice.setAttribute('stroke-dasharray', pathLength); slice.setAttribute('stroke-dashoffset', pathLength); const animateSlice = this.createSVGElement('animate', { attributeName: 'stroke-dashoffset', from: pathLength, to: '0', dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); slice.appendChild(animateSlice); } pieGroup.appendChild(slice); if (this.options.showDataLabels) { const labelRadius = radius * 1.2; const labelX = centerX + labelRadius * Math.cos(midAngle); const labelY = centerY + labelRadius * Math.sin(midAngle); const x = centerX + radius * Math.cos(midAngle); const y = centerY + radius * Math.sin(midAngle); const lineVertical = this.createSVGElement('line', { x1: x, y1: y, x2: labelX, y2: labelY, stroke: color, 'stroke-width': '1' }); if (this.options.animation) { const animateVerticalLineX = this.createSVGElement('animate', { attributeName: 'x2', from: x, to: labelX, dur: '0.5s', fill: 'freeze' }); lineVertical.appendChild(animateVerticalLineX); const animateVerticalLine = this.createSVGElement('animate', { attributeName: 'y2', from: y, to: labelY, dur: '0.5s', fill: 'freeze' }); lineVertical.appendChild(animateVerticalLine); } pieGroup.appendChild(lineVertical); const horizontalLineLength = 5; const horizontalLineXEnd = labelX > centerX ? labelX + horizontalLineLength : labelX - horizontalLineLength; const lineHorizontal = this.createSVGElement('line', { x1: labelX, y1: labelY, y2: labelY, stroke: color, 'stroke-width': '1' }); if (this.options.animation) { lineHorizontal.setAttribute('x2', labelX); const animateHorizontalLine = this.createSVGElement('animate', { attributeName: 'x2', from: labelX, to: horizontalLineXEnd, dur: '0.5s', begin: `${this.options.animationDuration / 2}ms`, fill: 'freeze' }); lineHorizontal.appendChild(animateHorizontalLine); } else { lineHorizontal.setAttribute('x2', horizontalLineXEnd); } pieGroup.appendChild(lineHorizontal); const label = this.createSVGElement('text', { x: horizontalLineXEnd + (labelX > centerX ? 5 : -5), y: labelY, 'text-anchor': labelX > centerX ? 'start' : 'end', 'alignment-baseline': 'middle', 'font-size': this.options.labelFontSize, fill: color }); label.textContent = this.getLabelContent(series, point); if (this.options.animation) { label.setAttribute('opacity', '0'); const animateOpacity = this.createSVGElement('animate', { attributeName: 'opacity', from: '0', to: '1', dur: '0.5s', begin: `${this.options.animationDuration / 2}ms`, fill: 'freeze' }); label.appendChild(animateOpacity); const animatePosition = this.createSVGElement('animateTransform', { attributeName: 'transform', type: 'translate', to: `0,0`, from: labelX > centerX ? `-${horizontalLineLength + 5},0` : `${horizontalLineLength + 5},0`, dur: '0.5s', begin: `${this.options.animationDuration / 2}ms`, fill: 'freeze' }); label.appendChild(animatePosition); } pieGroup.appendChild(label); } startAngle = endAngle; }); }); if (isDonut) { const donutHole = this.createSVGElement('circle', { cx: centerX, cy: centerY, r: Math.max(0, radius - this.options.donutThickness), fill: this.options.backgroundColor }); if (this.options.gap <= 0 && this.options.borderWidth > 0) { donutHole.setAttribute('stroke', this.options.borderColor || '#000'); donutHole.setAttribute('stroke-width', this.options.borderWidth); } pieGroup.appendChild(donutHole); } if (this.options.showCenterText) { const centerText = this.createSVGElement('text', { x: centerX, y: centerY, 'text-anchor': 'middle', 'alignment-baseline': 'middle', 'font-size': this.options.fontSize, fill: this.options.textColor, 'font-weight': 'bold' }); centerText.textContent = this.options.centerText !== null ? this.options.centerText : isDonut ? `Total: ${total}` : ''; this.svg.appendChild(centerText); } this.svg.appendChild(pieGroup); } /** * Draws a gauge chart based on the current data. */ drawGauge() { const centerX = this.margin.left + (this.width - this.margin.left - this.margin.right) / 2; const centerY = this.margin.top + (this.height - this.margin.top - this.margin.bottom) / 2; const radius = this.options.showLegend ? Math.min(this.width - this.margin.left - this.margin.right, this.height - this.margin.top - this.margin.bottom) / 1.8 : Math.min(this.width - this.margin.left - this.margin.right, this.height - this.margin.top - this.margin.bottom) / 2; const startAngle = -Math.PI * 0.75; const endAngle = Math.PI * 0.75; const background = this.createSVGElement('path', { d: this.describeArc(centerX, centerY, radius, startAngle, endAngle), fill: 'none', stroke: this.options.gridColor, 'stroke-width': this.options.gaugeCurveWidth }); this.svg.appendChild(background); const value = this.data[0].data[0].value; const maxValue = this.options.maxGaugeValue || 100; const percentage = (value / maxValue) * 100; const valueAngle = startAngle + (percentage / 100) * (endAngle - startAngle); const valuePath = this.createSVGElement('path', { d: this.describeArc(centerX, centerY, radius, startAngle, valueAngle), fill: 'none', stroke: this.data[0].color || this.options.colors[0], 'stroke-width': this.options.gaugeCurveWidth, 'stroke-linecap': 'round' }); if (typeof this.options.onClick === 'function') { valuePath.style.cursor = 'pointer'; valuePath.addEventListener('click', () => { this.options.onClick({ type: 'gauge', series: this.data[0], data: this.data[0].data[0] }); }); } if (this.options.animation) { const length = valuePath.getTotalLength(); valuePath.setAttribute('stroke-dasharray', length); valuePath.setAttribute('stroke-dashoffset', length); const animateGauge = this.createSVGElement('animate', { attributeName: 'stroke-dashoffset', from: length, to: '0', dur: `${this.options.animationDuration}ms`, fill: 'freeze' }); valuePath.appendChild(animateGauge); } this.svg.appendChild(valuePath); if (this.options.showCenterText) { const centerText = this.createSVGElement('text', { x: centerX, y: centerY, 'text-anchor': 'middle', 'dominant-baseline': 'middle', 'font-size': this.options.fontSize, 'font-family': this.options.fontFamily, fill: this.options.textColor, 'font-weight': 'bold' }); centerText.textContent = this.options.centerText !== null ? this.options.centerText : `${this.formatValue(percentage)}%`; this.svg.appendChild(centerText); const label = this.createSVGElement('text', { x: centerX, y: centerY + 30, 'text-anchor': 'middle', 'font-size': this.options.labelFontSize, 'font-family': this.options.fontFamily, fill: this.options.textColor }); label.textContent = this.data[0].data[0].label || ''; this.svg.appendChild(label); } } /** * Draws the legend for the graph. */ drawLegend() { if (!this.options.showLegend) { return; } if (this.legend && this.legend.parentNode === this.svg) { this.svg.removeChild(this.legend); } this.legend = this.createSVGElement('g', {class: 'legend'}); let startX, startY, stepX, stepY; const padding = 10; switch (this.options.legendPosition) { case 'top': startX = this.margin.left; startY = padding; stepX = 120; stepY = 0; break; case 'bottom': startX = this.margin.left; startY = this.height - padding; stepX = 120; stepY = 0; break; case 'left': startX = padding; startY = this.margin.top; stepX = 0; stepY = 25; break; case 'right': startX = this.width - this.margin.right - 100 - padding; startY = this.margin.top; stepX = 0; stepY = 25; break; default: startX = this.margin.left; startY = this.height - padding; stepX = 120; stepY = 0; } this.data.forEach((series, index) => { const color = series.color || this.options.colors[index % this.options.colors.length]; const x = startX + index * stepX; const y = startY + index * stepY; const rect = this.createSVGElement('rect', { x: x, y: y - 10, width: 20, height: 20, fill: color }); const text = this.createSVGElement('text', { x: x + 25, y: y + 5, 'font-size': this.options.labelFontSize, 'font-family': this.options.fontFamily, fill: this.options.textColor }); text.textContent = series.name || `Series ${index + 1}`; this.legend.appendChild(rect); this.legend.appendChild(text); }); this.svg.appendChild(this.legend); } /** * Retrieves the tooltip content for a data point. * @param {Object} series - The data series. * @param {Object} point - The data point. * @returns {string} The tooltip content. */ getTooltipContent(series, point) { if (this.options.tooltipFormatter) { return this.options.tooltipFormatter(series, point); } return `${series.name}: ${point.label} - ${point.value}`; } /** * Retrieves the label content for a data point. * @param {Object} series - The data series. * @param {Object} point - The data point. * @returns {string} The label content. */ getLabelContent(series, point) { if (this.currentChartType === 'pie' || this.currentChartType === 'donut') { return `${point.label}: ${this.formatValue((point.value / this.getTotal(series)) * 100)}%`; } else { return this.formatValue(point.value); } } /** * Formats a numerical value for display. * @param {number} value - The value to format. * @returns {string} The formatted value. */ formatValue(value) { if (Number.isInteger(value)) { return value.toString(); } return value.toFixed(1); } /** * Estimates the width of a given text string. * @param {string} text - The text to measure. * @returns {number} The estimated width in pixels. */ estimateTextWidth(text) { const tempText = this.createSVGElement('text', { 'font-size': this.options.labelFontSize, 'font-family': this.options.fontFamily }); tempText.textContent = text; this.svg.appendChild(tempText); const bbox = tempText.getBBox(); this.svg.removeChild(tempText); return bbox.width; } /** * Retrieves the total value of a series. * @param {Object} series - The data series. * @returns {number} The total value. */ getTotal(series) { return series.data.reduce((sum, point) => sum + point.value, 0); } /** * Sets the margins of the graph based on the presence of the legend and other options. */ setMargins() { let margin = {top: 50, right: 50, bottom: 50, left: 50}; if (this.options.showLegend) { switch (this.options.legendPosition) { case 'top': margin.top += 50; break; case 'bottom': margin.bottom += 50; break; case 'left': margin.left += 100; break; case 'right': margin.right += 100; break; default: margin.bottom += 50; } } else { if (['pie', 'donut'].includes(this.options.type)) { margin = {top: 20, right: 20, bottom: 20, left: 20}; } else if (!this.options.showAxisLabels) { margin = {top: 10, right: 10, bottom: 10, left: 10}; } } this.margin = margin; } /** * Loads and processes data from an HTML table. * @param {HTMLTableElement} table - The table element to load data from. * @returns {Array} The processed series data. */ loadAndProcessTableData(table) { const tableData = this.loadFromTable(table); return this.processTableData(tableData); } /** * Calculates a nice range for the y-axis and updates the internal state. */ calculateNiceRange() { const range = this.maxValue - this.minValue; if (range === 0) { this.minNice = this.minValue - 1; this.maxNice = this.maxValue + 1; return; } const roughStep = range / 5; const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); const niceStep = Math.ceil(roughStep / magnitude) * magnitude; this.minNice = Math.floor(this.minValue / niceStep) * niceStep; this.maxNice = Math.ceil(this.maxValue / niceStep) * niceStep; if (this.minValue > 0) { if (this.minValue === this.minNice) { this.minNice = Math.max(0, this.minNice - niceStep); } if (this.maxValue === this.maxNice) { this.maxNice += niceStep; } } if (this.maxValue < 0) { if (this.maxValue === this.maxNice) { this.maxNice = Math.min(0, this.maxNice + niceStep); } if (this.minValue === this.minNice) { this.minNice -= niceStep; } } } /** * Loads data from a table and renders the graph. */ initialize() { this.clear(); this.calculateFontSize(); this.setMargins(); if (this.options.table) { const table = document.getElementById(this.options.table); if (table) { const processedData = this.loadAndProcessTableData(table); this.setData(processedData); } else { console.warn(`Table with ID "${this.options.table}" not found.`); } } else if (this.options.data) { this.setData(this.options.data); } this.renderGraph(); } }