Greasy Fork is available in English.

FlowComments

コメントを流すやつ

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/444119/1061612/FlowComments.js

// ==UserScript==
// @name         FlowComments
// @namespace    https://midra.me
// @version      1.0.6
// @description  コメントを流すやつ
// @author       Midra
// @license      MIT
// @grant        none
// @compatible   chrome >= 84
// @compatible   safari >= 15
// @compatible   firefox >= 90
// ==/UserScript==

// @ts-check

'use strict'

/**
 * `FlowComments`のスタイル
 * @typedef  {object} FlowCommentsStyle
 * @property {string} [fontFamily]  フォント
 * @property {string} [fontWeight]  フォントの太さ
 * @property {number} [fontScale]   拡大率
 * @property {string} [color]       フォントカラー
 * @property {string} [shadowColor] シャドウの色
 * @property {number} [shadowBlur]  シャドウのぼかし
 * @property {number} [opacity]     透明度
 */

/**
 * `FlowCommentsItem`のオプション
 * @typedef  {object} FlowCommentsItemOption
 * @property {number} [position] 表示位置
 * @property {number} [duration] 表示時間
 */

/**
 * `FlowComments`のオプション
 * @typedef  {object}  FlowCommentsOption
 * @property {number}  [resolution]     解像度
 * @property {number}  [lines]          行数
 * @property {number}  [limit]          画面内に表示するコメントの最大数
 * @property {boolean} [autoResize]     サイズ(比率)を自動で調整
 * @property {boolean} [autoResolution] 解像度を自動で調整
 * @property {boolean} [smoothRender]   カクつきを抑える(負荷高いかも)
 */

/****************************************
 * デフォルト値
 */
const FLOWCMT_CONFIG = Object.freeze({
  /** フォントファミリー */
  FONT_FAMILY: [
    'Arial',
    '"ヒラギノ角ゴシック"', '"Hiragino Sans"',
    '"游ゴシック体"', 'YuGothic', '"游ゴシック"', '"Yu Gothic"',
    'Gulim', '"Malgun Gothic"',
    '"黑体"', 'SimHei',
    'system-ui', '-apple-system',
    'sans-serif',
  ].join(),

  /** フォントの太さ */
  FONT_WEIGHT: /Android/.test(window.navigator.userAgent) ? '700' : '600',

  /** フォントの拡大率 */
  FONT_SCALE: 0.7,

  /** フォントのY軸のオフセット */
  FONT_OFFSET_Y: 0.15,

  /** テキストの色 */
  TEXT_COLOR: '#fff',

  /** テキストシャドウの色 */
  TEXT_SHADOW_COLOR: '#000',

  /** テキストシャドウのぼかし */
  TEXT_SHADOW_BLUR: 1,

  /** テキスト間の余白(配列形式の場合) */
  TEXT_MARGIN: 0.2,

  /** Canvasのクラス名 */
  CANVAS_CLASSNAME: 'mid-FlowComments',

  /** Canvasの比率 */
  CANVAS_RATIO: 16 / 9,

  /** Canvasの解像度 */
  CANVAS_RESOLUTION: 720,

  /** 解像度のリスト */
  RESOLUTION_LIST: [240, 360, 480, 720],

  /** コメントの表示時間 */
  CMT_DISPLAY_DURATION: 6000,

  /** コメントの最大数(0は無制限) */
  CMT_LIMIT: 0,

  /** 行数 */
  LINES: 11,

  /** 比率の自動調整 */
  AUTO_RESIZE: true,

  /** 解像度の自動調整 */
  AUTO_RESOLUTION: true,
})

/****************************************
 * コメントの種類
 */
const FLOWCMT_TYPE = Object.freeze({
  // 流す
  FLOW: 0,
  // 上部に固定
  TOP: 1,
  // 下部に固定
  BOTTOM: 2,
})

/****************************************
 * @type {FlowCommentsItemOption}
 */
const FLOWCMTITEM_DEFAULT_OPTION = Object.freeze({
  position: FLOWCMT_TYPE.FLOW,
  duration: FLOWCMT_CONFIG.CMT_DISPLAY_DURATION,
})

/****************************************
 * @type {FlowCommentsOption}
 */
const FLOWCMT_DEFAULT_OPTION = Object.freeze({
  resolution: FLOWCMT_CONFIG.CANVAS_RESOLUTION,
  lines: FLOWCMT_CONFIG.LINES,
  limit: FLOWCMT_CONFIG.CMT_LIMIT,
  autoResize: FLOWCMT_CONFIG.AUTO_RESIZE,
  autoResolution: FLOWCMT_CONFIG.AUTO_RESOLUTION,
  smoothRender: false,
})

/****************************************
 * @type {FlowCommentsStyle}
 */
const FLOWCMT_DEFAULT_STYLE = Object.freeze({
  fontFamily: FLOWCMT_CONFIG.FONT_FAMILY,
  fontWeight: FLOWCMT_CONFIG.FONT_WEIGHT,
  fontScale: 1,
  color: FLOWCMT_CONFIG.TEXT_COLOR,
  shadowColor: FLOWCMT_CONFIG.TEXT_SHADOW_COLOR,
  shadowBlur: FLOWCMT_CONFIG.TEXT_SHADOW_BLUR,
  opacity: 1,
})

/****************************************
 * @classdesc ユーティリティ
 */
class FlowCommentsUtil {
  /****************************************
   * オブジェクトのプロパティからnullとundefinedを除去
   * @param {object} obj オブジェクト
   */
  static filterObject(obj) {
    if (typeof obj === 'object' && !Array.isArray(obj) && obj !== undefined && obj !== null) {
      Object.keys(obj).forEach(key => {
        if (obj[key] === undefined || obj[key] === null) {
          delete obj[key]
        } else {
          this.filterObject(obj[key])
        }
      })
    }
  }

  /****************************************
   * Canvasにスタイルを適用
   * @param {CanvasRenderingContext2D} ctx        CanvasRenderingContext2D
   * @param {FlowCommentsStyle}        style      スタイル
   * @param {number}                   resolution 解像度
   * @param {number}                   fontSize   フォントサイズ
   */
  static setStyleToCanvas(ctx, style, resolution, fontSize) {
    ctx.textBaseline = 'middle'
    ctx.lineJoin = 'round'
    ctx.font = `${style.fontWeight} ${fontSize * style.fontScale}px ${style.fontFamily}`
    ctx.fillStyle = style.color
    ctx.shadowColor = style.shadowColor
    ctx.shadowBlur = resolution / 400 * style.shadowBlur
    ctx.globalAlpha = style.opacity
  }
}

/****************************************
 * @classdesc 画像キャッシュ管理用
 */
class FlowCommentsImageCache {
  /**
   * オプション(デフォルト値)
   */
  static #OPTION = {
    maxSize: 50,
  }
  /**
   * キャッシュ
   * @type {{ [url: string]: { img: HTMLImageElement; lastUsed: number; }; }}
   */
  static #cache = {}

  /****************************************
   * キャッシュ追加
   * @param {string}           url URL
   * @param {HTMLImageElement} img 画像
   */
  static add(url, img) {
    // 削除
    if (this.#OPTION.maxSize < Object.keys(this.#cache).length) {
      let delCacheUrl
      Object.keys(this.#cache).forEach(key => {
        if (
          delCacheUrl === undefined ||
          this.#cache[key].lastUsed < this.#cache[delCacheUrl].lastUsed
        ) {
          delCacheUrl = key
        }
      })
      this.dispose(delCacheUrl)
    }

    // 追加
    this.#cache[url] = {
      img: img,
      lastUsed: Date.now(),
    }
  }

  /****************************************
   * 画像が存在するか
   * @param {string} url URL
   */
  static has(url) {
    return this.#cache.hasOwnProperty(url)
  }

  /****************************************
   * 画像を取得
   * @param {string} url URL
   * @returns {Promise<HTMLImageElement>} 画像
   */
  static async get(url) {
    return new Promise(async (resolve, reject) => {
      if (this.has(url)) {
        this.#cache[url].lastUsed = Date.now()
        resolve(this.#cache[url].img)
      } else {
        try {
          let img = new Image()
          img.addEventListener('load', ({ target }) => {
            if (target instanceof HTMLImageElement) {
              this.add(target.src, target)
              resolve(this.#cache[target.src].img)
            } else {
              reject()
            }
          })
          img.addEventListener('error', reject)
          img.src = url
          img = null
        } catch (e) {
          reject(e)
        }
      }
    })
  }

  /****************************************
   * 画像を解放
   * @param {string} url URL
   */
  static dispose(url) {
    if (this.has(url)) {
      this.#cache[url].img.remove()
      delete this.#cache[url]
    }
  }
}

/****************************************
 * @classdesc `FlowCommentsItem`用の画像クラス
 */
class FlowCommentsImage {
  /**
   * URL
   * @type {string}
   */
  #url
  /**
   * 代替テキスト
   * @type {string}
   */
  #alt

  /****************************************
   * コンストラクタ
   * @param {string} url   URL
   * @param {string} [alt] 代替テキスト
   */
  constructor(url, alt) {
    this.#url = url
    this.#alt = alt
  }

  get url() { return this.#url }
  get alt() { return this.#alt }

  /****************************************
   * 画像を取得
   * @returns {Promise<HTMLImageElement | string>}
   */
  async get() {
    try {
      return (await FlowCommentsImageCache.get(this.#url))
    } catch (e) {
      return this.#alt
    }
  }
}

/****************************************
 * @classdesc 流すコメント
 * @example
 * // idを指定する場合
 * const fcItem1 = new FlowCommentsItem('1518633760656605184', 'ウルトラソウッ')
 * // idを指定しない場合
 * const fcItem2 = new FlowCommentsItem(Symbol(), 'みどらんかわいい!')
 */
class FlowCommentsItem {
  /**
   * コメントID
   * @type {string | number | symbol}
   */
  #id
  /**
   * コメント本文
   * @type {Array<string | FlowCommentsImage>}
   */
  #content
  /**
   * オプション
   * @type {FlowCommentsItemOption}
   */
  #option
  /**
   * スタイル
   * @type {FlowCommentsStyle}
   */
  #style
  /**
   * 実際の表示時間
   * @type {number}
   */
  #actualDuration
  /**
   * コメント単体を描画したCanvas
   * @type {HTMLCanvasElement}
   */
  #canvas

  /**
   * 座標
   * @type {{ x: number; y: number; xp: number; offsetY: number; }}
   */
  position = {
    x: 0,
    y: 0,
    xp: 0,
    offsetY: 0,
  }
  /**
   * 描画サイズ
   * @type {{ width: number; height: number; }}
   */
  size = {
    width: 0,
    height: 0,
  }
  /**
   * 実際に流すときの距離
   * @type {number}
   */
  scrollWidth = 0
  /**
   * 行番号
   * @type {number}
   */
  line = 0
  /**
   * コメントを流し始めた時間
   * @type {number}
   */
  startTime = null

  /****************************************
   * コンストラクタ
   * @param {string | number | symbol}          id       コメントID
   * @param {Array<string | FlowCommentsImage>} content  コメント本文
   * @param {FlowCommentsItemOption}            [option] オプション
   * @param {FlowCommentsStyle}                 [style]  スタイル
   */
  constructor(id, content, option, style) {
    FlowCommentsUtil.filterObject(option)
    FlowCommentsUtil.filterObject(style)
    this.#id = id
    this.#content = Array.isArray(content) ? content.filter(v => v) : content
    this.#style = style
    this.#option = { ...FLOWCMTITEM_DEFAULT_OPTION, ...option }
    if (this.#option.position === FLOWCMT_TYPE.FLOW) {
      this.#actualDuration = this.#option.duration * 1.5
    }
    this.#canvas = document.createElement('canvas')
  }

  get id() { return this.#id }
  get content() { return this.#content }
  get style() { return this.#style }
  get option() { return this.#option }
  get actualDuration() { return this.#actualDuration }
  get canvas() { return this.#canvas }

  get top() { return this.position.y }
  get bottom() { return this.position.y + this.size.height }
  get left() { return this.position.x }
  get right() { return this.position.x + this.size.width }

  get rect() {
    return {
      width: this.size.width,
      height: this.size.height,
      top: this.top,
      bottom: this.bottom,
      left: this.left,
      right: this.right,
    }
  }

  dispose() {
    this.#canvas.remove()

    this.#id = null
    this.#content = null
    this.#style = null
    this.#option = null
    this.#actualDuration = null
    this.#canvas = null

    Object.keys(this).forEach(k => delete this[k])
  }
}

/****************************************
 * @classdesc コメントを流すやつ
 * @example
 * // 準備
 * const fc = new FlowComments()
 * document.body.appendChild(fc.canvas)
 * fc.start()
 * 
 * // コメントを流す(追加する)
 * fc.pushComment(new FlowCommentsItem(Symbol(), 'Hello world!'))
 */
class FlowComments {
  /**
   * インスタンスに割り当てられるIDのカウント用
   * @type {number}
   */
  static #id_cnt = 0

  /**
   * インスタンスに割り当てられるID
   * @type {number}
   */
  #id
  /**
   * `requestAnimationFrame`の`requestID`
   * @type {number}
   */
  #animReqId = null
  /**
   * Canvas
   * @type {HTMLCanvasElement}
   */
  #canvas
  /**
   * CanvasRenderingContext2D
   * @type {CanvasRenderingContext2D}
   */
  #context2d
  /**
   * 現在表示中のコメント
   * @type {Array<FlowCommentsItem>}
   */
  #comments
  /**
   * オプション
   * @type {FlowCommentsOption}
   */
  #option
  /**
   * スタイル
   * @type {FlowCommentsStyle}
   */
  #style
  /**
   * @type {ResizeObserver}
   */
  #resizeObs

  /****************************************
   * コンストラクタ
   * @param {FlowCommentsOption} [option] オプション
   * @param {FlowCommentsStyle}  [style]  スタイル
   */
  constructor(option, style) {
    // 初期化
    this.initialize(option, style)
  }

  get id() { return this.#id }
  get style() { return { ...FLOWCMT_DEFAULT_STYLE, ...this.#style } }
  get option() { return { ...FLOWCMT_DEFAULT_OPTION, ...this.#option } }
  get canvas() { return this.#canvas }
  get context2d() { return this.#context2d }
  get comments() { return this.#comments }

  get lineHeight() { return this.#canvas.height / this.option.lines }
  get fontSize() { return this.lineHeight * FLOWCMT_CONFIG.FONT_SCALE }

  get isStarted() { return this.#animReqId !== null }

  /****************************************
   * 初期化(インスタンス生成時には不要)
   * @param {FlowCommentsOption} [option] オプション
   * @param {FlowCommentsStyle}  [style]  スタイル
   */
  initialize(option, style) {
    this.dispose()

    // ID割り当て
    this.#id = ++FlowComments.#id_cnt

    // Canvas生成
    this.#canvas = document.createElement('canvas')
    this.#canvas.classList.add(FLOWCMT_CONFIG.CANVAS_CLASSNAME)
    this.#canvas.dataset.fcid = this.#id.toString()

    // CanvasRenderingContext2D
    this.#context2d = this.#canvas.getContext('2d')

    // コメント一覧
    this.#comments = []

    // サイズ変更を監視
    this.#resizeObs = new ResizeObserver(entries => {
      entries.forEach(entry => {
        const { width, height } = entry.contentRect

        // Canvasのサイズ(比率)を自動で調整
        if (this.option.autoResize) {
          const rect_before = this.#canvas.width / this.#canvas.height
          const rect_resized = width / height
          if (0.01 < Math.abs(rect_before - rect_resized)) {
            this.resizeCanvas()
          }
        }

        // Canvasの解像度を自動で調整
        if (this.option.autoResolution) {
          const resolution = FLOWCMT_CONFIG.RESOLUTION_LIST.find(v => height <= v)
          if (Number.isFinite(resolution) && this.option.resolution !== resolution) {
            this.changeOption({ resolution: resolution })
          }
        }
      })
    })
    this.#resizeObs.observe(this.#canvas)

    // オプションをセット
    this.changeOption(option)
    // スタイルをセット
    this.changeStyle(style)
  }

  /****************************************
   * オプションを変更
   * @param {FlowCommentsOption} option オプション
   */
  changeOption(option) {
    FlowCommentsUtil.filterObject(option)
    this.#option = { ...this.#option, ...option }
    if (option !== undefined && option !== null) {
      this.resizeCanvas()
    }
  }

  /****************************************
   * スタイルを変更
   * @param {FlowCommentsStyle} [style] スタイル
   */
  changeStyle(style) {
    FlowCommentsUtil.filterObject(style)
    this.#style = { ...this.#style, ...style }
    if (style !== undefined && style !== null) {
      this.#updateCanvasStyle()
    }
  }

  /****************************************
   * Canvasをリサイズ
   */
  resizeCanvas() {
    // Canvasをリサイズ
    const { width, height } = this.#canvas.getBoundingClientRect()
    const { resolution } = this.option
    const ratio = (width === 0 && height === 0) ? FLOWCMT_CONFIG.CANVAS_RATIO : (width / height)
    this.#canvas.width = resolution * ratio
    this.#canvas.height = resolution

    // Canvasのスタイルをリセット
    this.#updateCanvasStyle()
  }

  /****************************************
   * Canvasのスタイルを更新
   */
  #updateCanvasStyle() {
    // スタイルを適用
    FlowCommentsUtil.setStyleToCanvas(
      this.#context2d, this.style, this.option.resolution, this.fontSize
    )

    // Canvasをリセット
    this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
    // コメントの各プロパティを再計算・描画
    this.#comments.forEach(cmt => {
      this.#generateCommentsItemCanvas(cmt)
      this.#renderComment(cmt)
    })
  }

  /****************************************
   * Canvasのスタイルをリセット
   */
  resetCanvasStyle() {
    this.changeStyle(FLOWCMT_DEFAULT_STYLE)
  }

  /****************************************
   * 端数処理
   * @param {number} num
   */
  #floor(num) {
    return this.#option.smoothRender ? num : (num | 0)
  }

  /****************************************
   * コメントの単体のCanvasを生成
   * @param {FlowCommentsItem} comment コメント
   */
  async #generateCommentsItemCanvas(comment) {
    const ctx = comment.canvas.getContext('2d')
    ctx.clearRect(0, 0, comment.canvas.width, comment.canvas.height)

    const style = { ...this.style, ...comment.style }
    const drawFontSize = this.fontSize * style.fontScale
    const margin = drawFontSize * FLOWCMT_CONFIG.TEXT_MARGIN

    // スタイルを適用
    FlowCommentsUtil.setStyleToCanvas(
      ctx, style, this.option.resolution, this.fontSize
    )

    /** @type {Array<number>} */
    const aryWidth = []

    //----------------------------------------
    // サイズを計算
    //----------------------------------------
    for (const cont of comment.content) {
      // 文字列
      if (typeof cont === 'string') {
        aryWidth.push(ctx.measureText(cont).width)
      }
      // 画像
      else if (cont instanceof FlowCommentsImage) {
        const img = await cont.get()
        if (img instanceof HTMLImageElement) {
          const ratio = img.width / img.height
          aryWidth.push(drawFontSize * ratio)
        } else if (img !== undefined) {
          aryWidth.push(ctx.measureText(img).width)
        } else {
          aryWidth.push(1)
        }
      }
    }

    // コメントの各プロパティを計算
    comment.size.width = aryWidth.reduce((a, b) => a + b)
    comment.size.width += margin * (aryWidth.length - 1)
    comment.size.height = this.lineHeight
    comment.scrollWidth = this.#canvas.width + comment.size.width
    comment.position.x = this.#canvas.width - comment.scrollWidth * comment.position.xp
    comment.position.y = this.lineHeight * comment.line
    comment.position.offsetY = this.lineHeight / 2 * (1 + FLOWCMT_CONFIG.FONT_OFFSET_Y)

    // Canvasのサイズを設定
    comment.canvas.width = comment.size.width
    comment.canvas.height = comment.size.height

    // スタイルを再適用(上でリセットされる)
    FlowCommentsUtil.setStyleToCanvas(
      ctx, style, this.option.resolution, this.fontSize
    )

    //----------------------------------------
    // コメントを描画
    //----------------------------------------
    let dx = 0
    for (let idx = 0; idx < comment.content.length; idx++) {
      if (0 < idx) {
        dx += margin
      }
      const cont = comment.content[idx]
      // 文字列
      if (typeof cont === 'string') {
        ctx.fillText(
          cont,
          this.#floor(dx), this.#floor(comment.position.offsetY)
        )
      }
      // 画像
      else if (cont instanceof FlowCommentsImage) {
        const img = await cont.get()
        if (img instanceof HTMLImageElement) {
          ctx.drawImage(
            img,
            this.#floor(dx), this.#floor((comment.size.height - drawFontSize) / 2),
            this.#floor(aryWidth[idx]), this.#floor(drawFontSize)
          )
        } else if (img !== undefined) {
          ctx.fillText(
            img,
            this.#floor(dx), this.#floor(comment.position.offsetY)
          )
        } else {
          ctx.fillText(
            '',
            this.#floor(dx), this.#floor(comment.position.offsetY)
          )
        }
      }
      dx += aryWidth[idx]
    }
  }

  /****************************************
   * コメントを追加(流す)
   * @param {FlowCommentsItem} comment コメント
   */
  async pushComment(comment) {
    if (this.#animReqId === null || document.visibilityState === 'hidden') return

    //----------------------------------------
    // 画面内に表示するコメントを制限
    //----------------------------------------
    if (0 < this.option.limit && this.option.limit <= this.#comments.length) {
      this.#comments.splice(0, this.#comments.length - this.option.limit)[0]
    }

    //----------------------------------------
    // コメントの各プロパティを計算
    //----------------------------------------
    await this.#generateCommentsItemCanvas(comment)

    //----------------------------------------
    // コメント表示行を計算
    //----------------------------------------
    const spd_pushCmt = comment.scrollWidth / comment.option.duration

    // [[0, 0], [1, 0], ~ , [10, 0]] ([line, cnt])
    const lines_over = [...Array(this.option.lines)].map((_, i) => [i, 0])

    this.#comments.forEach(cmt => {
      // 残り表示時間
      const leftTime = cmt.option.duration * (1 - cmt.position.xp)
      // コメント追加時に重なる or 重なる予定かどうか
      const isOver =
        comment.left - spd_pushCmt * leftTime <= 0 ||
        comment.left <= cmt.right
      if (isOver && cmt.line < this.option.lines) {
        lines_over[cmt.line][1]++
      }
    })

    // 重なった頻度を元に昇順で並べ替える
    const lines_sort = lines_over.sort(([, cntA], [, cntB]) => cntA - cntB)

    comment.line = lines_sort[0][0]
    comment.position.y = this.lineHeight * comment.line

    //----------------------------------------
    // コメントを追加
    //----------------------------------------
    this.#comments.push(comment)
  }

  /****************************************
   * テキストを描画
   * @param {FlowCommentsItem} comment コメント
   */
  #renderComment(comment) {
    this.#context2d.drawImage(
      comment.canvas,
      this.#floor(comment.position.x), this.#floor(comment.position.y)
    )
  }

  /****************************************
   * ループ中に実行される処理
   * @param {number} time 時間
   */
  #update(time) {
    // Canvasをリセット
    this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)

    this.#comments.forEach((cmt, idx, ary) => {
      // コメントを流し始めた時間
      if (cmt.startTime === null) {
        cmt.startTime = time
      }

      // コメントを流し始めて経過した時間
      const elapsedTime = time - cmt.startTime

      if (elapsedTime <= cmt.actualDuration) {
        // コメントの座標を更新(流すコメント)
        if (cmt.option.position === FLOWCMT_TYPE.FLOW) {
          cmt.position.xp = elapsedTime / cmt.option.duration
          cmt.position.x = this.#canvas.width - cmt.scrollWidth * cmt.position.xp
        }
        // コメントを描画
        this.#renderComment(cmt)
      } else {
        // 表示時間を超えたら消す
        cmt.dispose()
        ary.splice(idx, 1)[0]
      }
    })
  }

  /****************************************
   * ループ処理
   * @param {number} time 時間
   */
  #loop(time) {
    this.#update(time)
    if (this.#animReqId !== null) {
      this.#animReqId = window.requestAnimationFrame(this.#loop.bind(this))
    }
  }

  /****************************************
   * コメント流しを開始
   */
  start() {
    if (this.#animReqId === null) {
      this.#animReqId = window.requestAnimationFrame(this.#loop.bind(this))
    }
  }

  /****************************************
   * コメント流しを停止
   */
  stop() {
    if (this.#animReqId !== null) {
      window.cancelAnimationFrame(this.#animReqId)
      this.#animReqId = null
    }
  }

  /****************************************
   * 解放(初期化してCanvasを削除)
   */
  dispose() {
    this.stop()

    this.#canvas?.remove()
    this.#resizeObs?.disconnect()

    this.#id = null
    this.#animReqId = null
    this.#canvas = null
    this.#context2d = null
    this.#comments = null
    this.#style = null
    this.#option = null
    this.#resizeObs = null

    Object.keys(this).forEach(k => delete this[k])
  }
}