ggraphs.js

55.24 KB
21/10/2024 12:26
JS
ggraphs.js
/**
 * 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<number>} 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<Object>} 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<Object>} 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<Object>} 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();
  }
}