Source: Progress.js

/**
 * Класс управления элементом прогресса
 * @class
 * @classdesc Прогресс
 * @author Leonid Bychkov [web@leobychkov.ru]
 * @date 30.09.2024
 * @link https://github.com/Sof7ik/progress-bar
 */
class Progress {
    /**
     * Ссылка на DOM-элемент
     * @type {SVGElement}
     * @private
     */
    _element = null;

    /**
     * Ссылка на DOM-элемент
     * @type {SVGCircleElement}
     * @private
     */
    _circlePercent = null;

    /**
     * Ссылка на DOM-элемент
     * @type {SVGCircleElement}
     * @private
     */
    _circleBackground = null;

    /**
     * Длина дуги
     * @type {number}
     * @private
     */
    _totalLength = 0;

    /**
     * Длина дуги, отображающей процент загрузки
     * @type {number}
     * @private
     */
    _loadedLength = 0;

    /**
     * Радиус круга
     * @type {number}
     * @private
     */
    _radius = 45;

    /**
     * Толщина круга
     * @type {number}
     * @private
     */
    _thickness = 10;

    /**
     * Процент выполнения (заливки)
     * @type {number}
     * @private
     */
    _value = 75;

    /**
     * @param {SVGElement} selector - Ссылка на SVG-элемент
     * @param {object} options - объект настроек
     * @param {Number} options.value - значение прогресса
     * @param {Boolean} options.animated - включать сразу анимацию вращения?
     * @param {Boolean} options.hidden - скрыть элемент по умолчанию?
     * @param {Number} options.radius - радиус круга
     * @param {Number} options.thickness - толщина круга
     * @param {SVGCircleElement | string} options.circlePercent - ссылка на DOM-элемент или ID элемента, отображающий процент выполнения
     * @param {SVGCircleElement | string} options.circleBackground - ссылка на DOM-элемент или ID элемента, отображающий дугу прогресса
     */
    constructor(selector, options = {}) {
        this.element = selector;

        this._init(options);
    }

    /**
     * Установить элемент, который является индикатором прогресса
     * @type {number}
     * @param value
     * @returns void
     */
    set element(value) {
        if (value instanceof SVGElement) {
            this._element = value;
        }
        else {
            throw new Error('Element must be an instance of SVGElement');
        }
    }

    /**
     * Получить DOM-элемент, который является прогрессом
     * @type {number}
     * @returns SVGElement
     */
    get element() {
        return this._element;
    }

    set value(value) {
        if (value < 0 || value > 100) {
            throw new Error("Значение прогресса должно быть в диапазоне от 0 до 100 включительно");
        }

        this._value = this._validateForNumber(this._value, "процент выполнения", value);
        this._changeRenderedValue();
    }

    get value() {
        return this._value;
    }

    /**
     * Получить текущий радиус круга прогресса
     * @returns {number}
     */
    get radius() {
        return this._radius;
    }

    /**
     * Установить новый радиус круга прогресса
     * @param {Number} value
     */
    set radius(value) {
        if (value < 0) {
            throw new Error("Радиус должен быть больше 0");
        }

        this._radius = this._validateForNumber(this._radius, "радиус", value);
        this._resize();
    }

    /**
     * Получить текущую толщину круга
     * @returns {number}
     */
    get thickness() {
        return this._thickness;
    }

    /**
     * Установить новую толщину круга прогресса
     * @param {Number} value
     */
    set thickness(value) {
        if (value < 0) {
            throw new Error("Толщина должна быть больше 0");
        }

        this._thickness = this._validateForNumber(this.thickness, "толщина", value);

        this._circlePercent.setAttribute("stroke-width", `${this.thickness}`);
        this._circleBackground.setAttribute("stroke-width", `${this.thickness}`);

        this._resize();
    }

    get totalLength() {
        return this._totalLength;
    }

    /**
     *
     * @param {object} options
     * @private
     */
    _init(options) {
        // init start values
        this._radius = this._validateForNumber(this.radius, "радиус", options.radius);
        this._thickness = this._validateForNumber(this.thickness, "толщина", options.thickness);
        this._value = this._validateForNumber(this.value, "процент выполнения", options.value);

        this._circlePercent = this._getElementBySelectorOrElement(options.circlePercent) ??
            this.element.querySelector('#progress-percent');

        this._circleBackground = this._getElementBySelectorOrElement(options.circleBackground) ??
            this.element.querySelector('#progress-bg');

        if (!this._circlePercent) {
            throw new Error("Ошибка при инициализации дуги прогресса");
        }
        if (!this._circleBackground) {
            throw new Error("Ошибка при инициализации дуги прогресса");
        }

        this.isAnimated = options.animated ?? false;
        this.isHidden = options.hidden ?? false;

        this._circlePercent.setAttribute("stroke-width", `${this.thickness}`);
        this._circleBackground.setAttribute("stroke-width", `${this.thickness}`);

        this._resize();

        if (this.isHidden) {
            this.hide();
        }

        if (!this.isHidden && this.isAnimated) {
            this.animate();
        }

        if (this.isAnimated && this.isHidden) {
            throw new Error("Блок не может быть скрытым и анимированным одновременно");
        }
    }

    /**
     * Проверяет, является ли переданное значение числом и возвращает его, иначе возвращает значение по умолчанию
     * @param {Number} defaultValue - значение по умолчанию
     * @param {String} paramName - название переменной. используется при отображении ошибки
     * @param value - значение, которое нужно проверить
     * @private
     */
    _validateForNumber(defaultValue, paramName, value = null) {
        if (value) {
            if (typeof value !== "number") {
                throw new Error(`${paramName} должен быть числом`)
            }
            return value;
        }
        else {
            return defaultValue;
        }
    }

    /**
     * Возвращает элемент по его селектору или сам элемент
     * @param {HTMLElement | string} value
     * @returns {HTMLElement|null}
     * @private
     */
    _getElementBySelectorOrElement(value) {
        if (typeof value === "string") {
            return document.getElementById(value);
        }
        else if (value instanceof SVGCircleElement) {
            return value;
        }
        else {
            return null;
        }
    }

    /**
     * Запуск анимации вращения
     * @returns void
     */
    animate() {
        this.isAnimated = true;
        this.element.classList.add('animated');
    }

    /**
     * Прекращение анимации вращения
     * @returns void
     */
    cancelAnimation() {
        if (!this.isAnimated) return;

        this.isAnimated = false;
        this.element.classList.remove('animated');
    }

    /**
     * Скрыть элемент прогресса
     * @returns void
     */
    hide() {
        this.isHidden = true;
        this.element.classList.add("hidden");
    }

    /**
     * Показать элемент прогресса
     * @returns void
     */
    show() {
        if (!this.isHidden) return;

        this.isHidden = false;
        this.element.classList.remove("hidden");
    }

    /**
     * Выводит новое значение прогресса
     * @private
     * @returns void
     */
    _changeRenderedValue() {
        // 100% = 2 * Math.PI * radius
        // при value = 25 нужно оставить 75% от длины
        // при value = 50 нужно оставить 50% от длины
        // при value = 75 нужно оставить 25% от длины

        // ищем % от длины дуги
        const percentageLength = Math.ceil(this.totalLength / 100 * this._value);
        this._loadedLength = this.totalLength - percentageLength;
        this._circlePercent.style.strokeDashoffset = `${this._loadedLength}px`;
        this._circlePercent.style.strokeDasharray = `${this.totalLength}px`;
    }

    /**
     * Изменение радиуса прогресса
     * @private
     */
    _resize() {
        this._totalLength = (2 * Math.PI * this.radius);

        this._circlePercent.setAttribute("r", `${this._radius}`);
        this._circlePercent.setAttribute("cx", `${this._radius + this.thickness / 2}`);
        this._circlePercent.setAttribute("cy", `${this._radius + this.thickness / 2}`);

        this._circleBackground.setAttribute("r", `${this._radius}`);
        this._circleBackground.setAttribute("cx", `${this._radius + this.thickness / 2}`);
        this._circleBackground.setAttribute("cy", `${this._radius + this.thickness / 2}`);

        this.element.setAttribute("width", `${(this._radius * 2) + this.thickness}`);
        this.element.setAttribute("height", `${(this._radius * 2) + this.thickness}`);

        // считаем новые длины
        this._changeRenderedValue();
    }
}