Задача №7

Відобразити падаючий сніг. Сніжинка з’являється у верхній частині екрана (top =0) і з випадковою швидкістю рухається вниз (у setInterval викликати метод, у якому додавати крок до top). Як тільки сніжинка досягає нижньої частини екрана (top>maxTop) вона знову повинна з’явитись у верхній частині екрана (top=0).


Рішення:

'use strict'

/**
 * @see {Snowflake}
 * @typedef {Object} SnowflakeOptions - Configuration options for a snowflake instance.
 * @property {number} [options.intervalMs=1000] - The update position interval in milliseconds.
 * @property {number} [options.minSize=36] - The minimum size of the snowflake.
 * @property {number} [options.maxSize=72] - The maximum size of the snowflake.
 * @property {number} [options.minHue=190] - The minimum hue value for the snowflake's color.
 * @property {number} [options.maxHue=210] - The maximum hue value for the snowflake's color.
 * @property {number} [options.minSaturation=30] - The minimum saturation percentage for the snowflake's color.
 * @property {number} [options.maxSaturation=80] - The maximum saturation percentage for the snowflake's color.
 * @property {number} [options.minLightness=70] - The minimum lightness percentage for the snowflake's color.
 * @property {number} [options.maxLightness=90] - The maximum lightness percentage for the snowflake's color.
 * @property {number} [options.minSpeed=45] - The minimal snowflake step.
 * @property {number} [options.maxSpeed=240] - The maximal snowflake step.
 */

/**
 * @param {number} from
 * @param {number} to
 * @returns {number}
 * @throws {TypeError}
 */
function getRandomInteger(from, to) {
  if (typeof from !== 'number' || typeof to !== 'number')
    throw TypeError('Range must be set in numbers.')

  return from + Math.floor(Math.random() * (to - from + 1))
}

class ColorHSL {
  /**
   * @param {number} hue
   * @param {number} saturation
   * @param {number} lightness
   */
  constructor(hue, saturation, lightness) {
    this.hue = hue
    this.saturation = saturation
    this.lightness = lightness
  }

  toString() {
    return `hsl(${this.hue}deg ${this.saturation}% ${this.lightness}%)`
  }
}

class Snowflake {
  /**
   * @param {string} containerSelector
   * @param {string} templateSelector
   * @param {SnowflakeOptions} [options]
   */
  constructor(containerSelector, templateSelector, options = {}) {
    this.templateSelector = templateSelector
    /** @type {SnowflakeOptions} */
    this.options = {
      intervalMs: 1000,
      minSize: 36,
      maxSize: 72,
      minHue: 190,
      maxHue: 210,
      minSaturation: 30,
      maxSaturation: 80,
      minLightness: 70,
      maxLightness: 90,
      minSpeed: 45,
      maxSpeed: 240,
      ...options,
    }

    this.intervalId = null
    this.size = getRandomInteger(this.options.minSize, this.options.maxSize)
    this.element = this.#createFromTemplate()
    this.#initializePositionAndColor()
    this.#applyInitialStyles()

    this.container = document.querySelector(containerSelector)

    if (!this.container)
      throw new ReferenceError(`Selector '${containerSelector}' not found.`)

    this.container.append(this.element)
  }

  get availableWidth() {
    return document.documentElement.clientWidth - this.size
  }

  get availableHeight() {
    return document.documentElement.clientHeight + this.size + this.speed
  }

  #createFromTemplate() {
    const template = document.querySelector(this.templateSelector)

    if (!(template instanceof HTMLTemplateElement))
      throw new TypeError("Can't find the template.")

    /** @type {HTMLElement}  */
    const snowflakeEl = template.content.firstElementChild.cloneNode(true)

    return snowflakeEl
  }

  #initializePositionAndColor() {
    this.top = -this.size
    this.left = getRandomInteger(0, this.availableWidth)
    this.color = new ColorHSL(
      getRandomInteger(this.options.minHue, this.options.maxHue),
      getRandomInteger(this.options.minSaturation, this.options.maxSaturation),
      getRandomInteger(this.options.minLightness, this.options.maxLightness),
    )

    this.speed = getRandomInteger(this.options.minSpeed, this.options.maxSpeed)
  }

  #applyInitialStyles() {
    this.element.style.top = -this.size + 'px'
    this.element.style.left = this.left + 'px'
    this.element.style.color = String(this.color)

    this.element.style.inlineSize = this.size + 'px'
    this.element.style.translate = `0 ${this.top}px`
  }

  fall() {
    if (this.intervalId) return

    this.intervalId = setInterval(() => {
      this.top += this.speed
      this.element.style.transitionDuration = this.options.intervalMs + 'ms'

      if (this.top > this.availableHeight) {
        this.element.style.transitionDuration = '0ms'
        this.#initializePositionAndColor()
        this.#applyInitialStyles()
      } else {
        this.element.style.translate = `0 ${this.top}px`
      }
    }, this.options.intervalMs)
  }

  stop() {
    if (!this.intervalId) return

    clearInterval(this.intervalId)
    this.intervalId = null
  }
}

class Snowfall {
  /**
   * @param {string} containerSelector
   * @param {string} templateSelector
   * @param {number} snowAmount
   * @param {SnowflakeOptions} options
   */
  constructor(containerSelector, templateSelector, snowAmount, options = {}) {
    this.container = document.querySelector(containerSelector)

    this.snowAmount = snowAmount

    /** @type {Snowflake[]} */
    this.snowflakes = Array.from(
      {length: snowAmount},
      () => new Snowflake(containerSelector, templateSelector, options),
    )
  }

  fall() {
    for (const snowflake of this.snowflakes) {
      snowflake.fall()
    }
  }

  stop() {
    for (const snowflake of this.snowflakes) {
      snowflake.stop()
    }
  }
}

// =============================================================================

const blizzard = new Snowfall('.js-snowfall', '.js-snowflake-template', 80)

blizzard.fall()