countdown-8.x-1.8/js/lib/countdown.js

js/lib/countdown.js
/*!
 * CountdownTimer v1.0.0-alpha2 — Drupal Countdown module.
 * https://drupal.org/project/countdown
 * (c) 2025 Mahyar SBT
 * @license GPL-2.0-or-later
 */
(function (root, factory) {
  'use strict';
  if (typeof define === 'function' && define.amd) {
    define([], factory);
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory();
  } else {
    root.CountdownTimer = factory();
  }
}(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  // Time constants
  const MS_IN_SEC = 1000;
  const MS_IN_MIN = 60000;
  const MS_IN_HOUR = 3600000;
  const MS_IN_DAY = 86400000;

  // Precision intervals
  const PRECISION_MS = {
    milliseconds: 1,
    hundredths: 10,
    tenths: 100,
    seconds: 1000,
    minutes: 60000
  };

  // Static regex patterns
  const PATTERNS = {
    ISO: /^\d{4}-\d{2}-\d{2}[T\s]/,
    RELATIVE: /(?:(\d+)d)?(?:\s*(\d+)h)?(?:\s*(\d+)m)?(?:\s*(\d+)s)?/,
    TIME: /^(?:(\d{1,2}):)?(?:(\d{1,2}):)?(\d{1,2})(?:[:.](\d{1,3}))?$/,
    NUMERIC: /^\d+$/
  };

  // Static padding helpers
  const pad = (n, len) => String(n).padStart(len, '0');
  const pad2 = n => n < 10 ? '0' + n : String(n);
  const pad3 = n => n < 10 ? '00' + n : n < 100 ? '0' + n : String(n);

  /**
   * Parse time inputs to milliseconds
   * @param {*} input - Time in various formats
   * @param {number} [now=Date.now()] - Reference timestamp
   * @returns {number} Milliseconds
   */
  const parseTime = (input, now = Date.now()) => {
    if (input == null) return 0;

    if (typeof input === 'number') {
      return input > 31536000000 ? Math.max(0, input - now) : Math.abs(input);
    }

    if (input instanceof Date) {
      return Math.max(0, input.getTime() - now);
    }

    if (typeof input === 'object') {
      const {days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0} = input;
      return Math.abs(days * MS_IN_DAY + hours * MS_IN_HOUR + minutes * MS_IN_MIN + seconds * MS_IN_SEC + milliseconds);
    }

    if (typeof input === 'string') {
      let match;

      if (PATTERNS.TIME.test(input)) {
        match = input.match(PATTERNS.TIME);
        const [, h, m, s, ms] = match;
        const hours = h && m ? parseInt(h, 10) : 0;
        const minutes = h && m ? parseInt(m, 10) : (h ? parseInt(h, 10) : 0);
        const seconds = parseInt(s, 10);
        const millis = ms ? parseInt(ms.padEnd(3, '0'), 10) : 0;
        return hours * MS_IN_HOUR + minutes * MS_IN_MIN + seconds * MS_IN_SEC + millis;
      }

      if (match = input.match(PATTERNS.RELATIVE)) {
        if (match[1] || match[2] || match[3] || match[4]) {
          return (parseInt(match[1] || 0, 10) * MS_IN_DAY +
            parseInt(match[2] || 0, 10) * MS_IN_HOUR +
            parseInt(match[3] || 0, 10) * MS_IN_MIN +
            parseInt(match[4] || 0, 10) * MS_IN_SEC);
        }
      }

      if (PATTERNS.ISO.test(input)) {
        const ts = Date.parse(input);
        return !isNaN(ts) ? Math.max(0, ts - now) : 0;
      }

      if (PATTERNS.NUMERIC.test(input)) {
        return parseTime(parseInt(input, 10), now);
      }
    }

    return 0;
  };

  /**
   * Convert milliseconds to time components
   * @param {number} ms - Milliseconds
   * @returns {Object} Time components
   */
  const msToComponents = ms => {
    const abs = Math.abs(ms);
    const days = Math.floor(abs / MS_IN_DAY);
    const hours = Math.floor((abs % MS_IN_DAY) / MS_IN_HOUR);
    const minutes = Math.floor((abs % MS_IN_HOUR) / MS_IN_MIN);
    const seconds = Math.floor((abs % MS_IN_MIN) / MS_IN_SEC);
    const milliseconds = Math.floor(abs % MS_IN_SEC);

    return {
      days,
      hours,
      minutes,
      seconds,
      milliseconds,
      tenths: Math.floor(milliseconds / 100),
      hundredths: Math.floor(milliseconds / 10)
    };
  };

  /**
   * Lightweight event emitter using Map
   * @private
   */
  class EventEmitter {
    constructor() {
      this._listeners = new Map();
    }

    on(event, fn) {
      const listeners = this._listeners.get(event) || [];
      listeners.push(fn);
      this._listeners.set(event, listeners);
      return () => this.off(event, fn);
    }

    off(event, fn) {
      const listeners = this._listeners.get(event);
      if (listeners) {
        const idx = listeners.indexOf(fn);
        if (idx > -1) listeners.splice(idx, 1);
      }
    }

    emit(event, data) {
      const listeners = this._listeners.get(event);
      if (listeners) listeners.slice().forEach(fn => fn(data));
    }

    clear() {
      this._listeners.clear();
    }
  }

  /**
   * CountdownTimer - Main timer class
   * @class
   */
  class CountdownTimer {
    /**
     * @param {Object} [config={}] - Configuration
     * @param {string} [config.selector] - DOM selector
     * @param {string} [config.mode='countdown'] - 'countdown' or 'countup'
     * @param {*} [config.start='00:10'] - Start time
     * @param {*} [config.target='00:00'] - Target time
     * @param {number} [config.duration] - Duration in seconds
     * @param {string} [config.precision='seconds'] - Time precision
     * @param {boolean} [config.driftCompensation=true] - Enable drift compensation
     * @param {number} [config.offset=0] - Start offset in ms
     * @param {boolean} [config.autoStart=false] - Auto-start
     * @param {boolean} [config.benchmark=false] - Enable benchmarking
     * @param {boolean} [config.debug=false] - Debug mode
     */
    constructor(config = {}) {
      this.config = {
        selector: null,
        mode: 'countdown',
        start: '00:10',
        target: '00:00',
        duration: null,
        precision: 'seconds',
        driftCompensation: true,
        offset: 0,
        autoStart: true,
        benchmark: false,
        debug: false,
        onStart: null,
        onPause: null,
        onResume: null,
        onStop: null,
        onReset: null,
        onComplete: null,
        onTick: null,
        ...config
      };

      // Backward-compatible alias: effect → style
      if (this.config.effect && !this.config.style) {
        this.config.style = this.config.effect;
      }

      if (!PRECISION_MS[this.config.precision]) {
        this.config.precision = 'seconds';
      }

      this._events = new EventEmitter();
      this._element = null;
      this._benchmarkData = null;
      this._styleRenderer = null;
      this._resetState();

      if (this.config.selector) {
        this._element = document.querySelector(this.config.selector);
      }

      this._parseConfig();

      if (this.config.benchmark) {
        this._benchmarkData = {ticks: 0, totalDrift: 0, maxDrift: 0};
      }

      if (this.config.autoStart) {
        this.start();
      }
    }

    _resetState() {
      this.state = {
        running: false,
        paused: false,
        startTime: 0,
        pauseTime: 0,
        targetMs: 0,
        timerId: null,
        nextTick: 0
      };
    }

    _parseConfig() {
      const {mode, start, target, duration} = this.config;

      if (mode === 'countdown') {
        this.state.targetMs = duration !== null ? duration * MS_IN_SEC : parseTime(start);
      } else {
        if (target && target !== '00:00') {
          this.state.targetMs = parseTime(target);
        } else if (duration !== null) {
          this.state.targetMs = duration * MS_IN_SEC;
        }
      }

      if (this.config.offset) {
        this.state.targetMs += this.config.offset;
      }
    }

    start(newConfig) {
      if (this.state.running) return this;

      if (newConfig) {
        Object.assign(this.config, newConfig);
        this._parseConfig();
      }

      if (this.config.selector && !this._element) {
        this._element = document.querySelector(this.config.selector);
      }

      const now = Date.now();
      const interval = PRECISION_MS[this.config.precision];

      if (this.state.paused) {
        const pauseDuration = now - this.state.pauseTime;
        this.state.startTime += pauseDuration;
        this.state.nextTick += pauseDuration;
      } else {
        const aligned = now - (now % interval);
        this.state.startTime = aligned;
        this.state.nextTick = aligned;
      }

      this.state.running = true;
      this.state.paused = false;

      this._tick();

      if (this.config.driftCompensation) {
        this._scheduleTick(interval);
      } else {
        this.state.timerId = setInterval(() => this._tick(), interval);
      }

      this._emit('start', this.getTime());
      if (this.config.onStart) this.config.onStart(this.getTime(), this);

      return this;
    }

    _scheduleTick(interval) {
      const schedule = () => {
        if (!this.state.running) return;

        const now = Date.now();
        const drift = now - this.state.nextTick;

        if (this._benchmarkData) {
          this._benchmarkData.totalDrift += Math.abs(drift);
          this._benchmarkData.maxDrift = Math.max(this._benchmarkData.maxDrift, Math.abs(drift));
          this._benchmarkData.ticks++;
        }

        if (drift > interval) {
          const missed = Math.floor(drift / interval);
          for (let i = 0; i < missed; i++) this._tick();
          this.state.nextTick += (missed + 1) * interval;
        } else {
          this._tick();
          this.state.nextTick += interval;
        }

        this.state.timerId = setTimeout(schedule, Math.max(0, this.state.nextTick - Date.now()));
      };

      this.state.timerId = setTimeout(schedule, Math.max(0, this.state.nextTick - Date.now()));
    }

    _tick() {
      if (this.config.selector && !this._element) {
        this._element = document.querySelector(this.config.selector);
      }

      const elapsed = this.getElapsed();
      const effectiveMs = this.config.mode === 'countdown'
        ? Math.max(0, this.state.targetMs - elapsed)
        : elapsed;

      if (this._checkCompletion(effectiveMs)) return;

      const formattedTime = this.formatTime();
      const timeComponents = this.getTime();

      // Style delegation
      if (this.config.style && CountdownTimer._styles && CountdownTimer._styles.has(this.config.style)) {
        if (!this._styleRenderer) {
          const StyleClass = CountdownTimer._styles.get(this.config.style);
          this._styleRenderer = new StyleClass(this, this.config.styleOptions);
        }
        if (this._element && this._styleRenderer.render) {
          this._styleRenderer.render(formattedTime, timeComponents);
        }
      } else {
        // Default text rendering
        if (this._element) {
          this._element.textContent = formattedTime;
        }
      }

      if (this.config.debug) {
        console.log('[CountdownTimer]', formattedTime, timeComponents);
      }

      this._emit('tick', timeComponents);
      if (this.config.onTick) this.config.onTick(timeComponents, this);
    }

    _checkCompletion(value) {
      const shouldComplete = this.config.mode === 'countdown'
        ? value <= 0 && this.state.running
        : this.state.targetMs > 0 && value >= this.state.targetMs && this.state.running;

      if (shouldComplete) {
        this.stop();
        this._emit('complete', this.getTime());
        if (this.config.onComplete) this.config.onComplete(this.getTime(), this);
        return true;
      }
      return false;
    }

    stop() {
      if (!this.state.running && !this.state.paused) return this;

      this._clearTimer();
      this._resetState();

      this._emit('stop', this.getTime());
      if (this.config.onStop) this.config.onStop(this.getTime(), this);

      return this;
    }

    pause() {
      if (!this.state.running || this.state.paused) return this;

      this._clearTimer();
      this.state.running = false;
      this.state.paused = true;
      this.state.pauseTime = Date.now();

      this._emit('pause', this.getTime());
      if (this.config.onPause) this.config.onPause(this.getTime(), this);

      return this;
    }

    resume() {
      if (!this.state.paused) return this;

      const now = Date.now();
      const interval = PRECISION_MS[this.config.precision];
      const pauseDuration = now - this.state.pauseTime;

      this.state.startTime += pauseDuration;
      this.state.nextTick = now + interval;
      this.state.running = true;
      this.state.paused = false;

      if (this.config.driftCompensation) {
        this._scheduleTick(interval);
      } else {
        this.state.timerId = setInterval(() => this._tick(), interval);
      }

      this._emit('resume', this.getTime());
      if (this.config.onResume) this.config.onResume(this.getTime(), this);

      return this;
    }

    reset(newConfig) {
      const wasRunning = this.state.running;
      this.stop();

      if (newConfig) {
        Object.assign(this.config, newConfig);
        this._parseConfig();
      }

      this._emit('reset', this.getTime());
      if (this.config.onReset) this.config.onReset(this.getTime(), this);

      if (wasRunning || this.config.autoStart) {
        this.start();
      }

      return this;
    }

    formatTime(template) {
      if (!template) {
        template = CountdownTimer.TEMPLATES[this.config.precision] || 'HH:MM:SS';
      }

      const components = this.getTime();
      if (!components) return '00:00:00';

      const values = this._getFormattedValues(components);
      return template.replace(CountdownTimer.FORMAT_REGEX, match => values[match] || match);
    }

    _getFormattedValues(c) {
      return {
        'DD': pad2(c.days || 0),
        'D': String(c.days || 0),
        'HH': pad2(c.hours || 0),
        'H': String(c.hours || 0),
        'MM': pad2(c.minutes || 0),
        'M': String(c.minutes || 0),
        'SS': pad2(c.seconds || 0),
        'S': String(c.seconds || 0),
        'mmm': pad3(c.milliseconds || 0),
        'mm': pad2(c.hundredths || 0),
        'm': String(c.tenths || 0)
      };
    }

    getElapsed() {
      if (!this.state.startTime) return 0;
      const ref = this.state.paused ? this.state.pauseTime : Date.now();
      return Math.max(0, ref - this.state.startTime);
    }

    getRemaining() {
      return this.config.mode === 'countdown' ? Math.max(0, this.state.targetMs - this.getElapsed()) : 0;
    }

    getTime() {
      const ms = this.config.mode === 'countdown' ? this.getRemaining() : this.getElapsed();
      return msToComponents(ms);
    }

    isRunning() {
      return this.state.running;
    }

    isPaused() {
      return this.state.paused;
    }

    on(event, callback) {
      return this._events.on(event, callback);
    }

    off(event, callback) {
      this._events.off(event, callback);
      return this;
    }

    getBenchmark() {
      if (!this._benchmarkData) return null;
      const {ticks, totalDrift, maxDrift} = this._benchmarkData;
      return {
        ticks,
        avgDrift: ticks > 0 ? totalDrift / ticks : 0,
        maxDrift,
        accuracy: ticks > 0 ? (1 - (totalDrift / ticks / PRECISION_MS[this.config.precision])) * 100 : 100
      };
    }

    _emit(event, data) {
      this._events.emit(event, data);
    }

    _clearTimer() {
      if (this.state.timerId) {
        this.config.driftCompensation ? clearTimeout(this.state.timerId) : clearInterval(this.state.timerId);
        this.state.timerId = null;
      }
    }

    destroy() {
      // Clean up style renderer
      if (this._styleRenderer && typeof this._styleRenderer.destroy === 'function') {
        this._styleRenderer.destroy();
        this._styleRenderer = null;
      }

      this.stop();
      this._events.clear();
      this.config = null;
      this.state = null;
      this._events = null;
      this._element = null;
      this._benchmarkData = null;
    }

    static async testAccuracy(duration = 60) {
      return new Promise(resolve => {
        const expectedMs = duration * 1000;
        const timer = new CountdownTimer({
          mode: 'countup',
          precision: 'milliseconds',
          benchmark: true,
          autoStart: true
        });

        setTimeout(() => {
          timer.stop();
          const elapsed = timer.getElapsed();
          const drift = Math.abs(elapsed - expectedMs);

          resolve({
            expected: expectedMs,
            actual: elapsed,
            drift,
            accuracy: (100 - drift / expectedMs * 100).toFixed(4) + '%',
            benchmark: timer.getBenchmark()
          });
        }, expectedMs);
      });
    }
  }

  // Static properties
  CountdownTimer.FORMAT_REGEX = /DD|D|HH|H|MM|M|SS|S|mmm|mm|m/g;
  CountdownTimer.TEMPLATES = {
    'milliseconds': 'HH:MM:SS.mmm',
    'hundredths': 'HH:MM:SS.mm',
    'tenths': 'HH:MM:SS.m',
    'seconds': 'HH:MM:SS',
    'minutes': 'HH:MM'
  };

  // Style registry
  CountdownTimer._styles = new Map();

  /**
   * Register a custom style renderer
   * @param {string} id - Style identifier
   * @param {Function} factory - Style class constructor
   */
  CountdownTimer.registerStyle = function(id, factory) {
    if (id && typeof factory === 'function') {
      CountdownTimer._styles.set(id, factory);
    }
  };

  // Convenience alias for backward compatibility
  CountdownTimer.registerEffect = CountdownTimer.registerStyle;

  return CountdownTimer;
}));

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc