<anime-image
  src="https://storage.googleapis.com/ys-notebook-public/ec-perfect-mobile-performance-score.avif"
  animeTime=8
  expansionRatio=1.3
/>
'use client'; // next.js で使用する場合に必要
const DefaultAnimeTime = 15; // アニメーション時間(秒)
const DefaultExpansionRatio = 1.15; // 拡大比率。1 以上の値にする。1.15 ならば15%拡大。実際の表示はオリジナルの85%

const startPaint = (info) => {
  info.startTime = undefined;
  const img = new Image();
  img.addEventListener("load", () => {
    // ワーク(オフスクリーン)canvas 作成、設定。チラつき防止。
    if (info.workCanvas == undefined) info.workCanvas = document.createElement("canvas");
    const aspect = img.naturalHeight / img.naturalWidth;
    const dstW = info.clientWidth * info.dpr * info.expansionRatio;
    const dstH = dstW * aspect;
    info.workCanvas.width = dstW;
    info.workCanvas.height = dstH;
    const workCtx = info.workCanvas.getContext('2d', { alpha: false });
    workCtx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, dstW, dstH);

    info.canvasW = info.clientWidth;
    info.canvasH = info.canvasW * aspect;
    info.canvas.width = info.canvasW * info.dpr;
    info.canvas.height = info.canvasH * info.dpr;
    info.canvas.style.width = info.canvasW + 'px';
    info.canvas.style.height = info.canvasH + 'px';

    // 多重動作抑止。初回、または50 msec 以内に画面更新していなければ描画する
    if (info.last_t == undefined || performance.now() - info.last_t > 50) animate(info);
  });
  img.src = info.src; 
}

const animate = (info) => {
  let frame = requestAnimationFrame(function loop(t) {
    if (paint(info, t)) {
      frame = requestAnimationFrame(loop);
    }
  });

  return () => {
    cancelAnimationFrame(frame);
  };
}

const paint = (info, t) => {
  info.last_t = t;
  if (info.startTime === undefined) {
    info.startTime = t;
    info.beforeTime = t;
    info.count = 0;
    // 1秒間に移動する比率
    info.ratioPerSec = (info.expansionRatio - 1.0) * 0.5 / info.animeTime;
  }

  const elapsed = (t - info.startTime) * 0.001; // 秒単位

  if (false) { // fps 計測ロジック
    ++info.count;
    if ((t - info.beforeTime) > 1000.0) {
      console.log(info.src + ": " + elapsed + ": "
        + Math.round(info.count / ((t - info.beforeTime) * 0.001)) + " fps");
      info.beforeTime = t;
      info.count = 0;
    }
  }

  const workC = info.workCanvas;
  info.pxPerSecX = info.canvas.width * info.ratioPerSec;
  info.pxPerSecY = info.canvas.height * info.ratioPerSec;
  info.x = info.pxPerSecX * -elapsed;
  info.y = info.canvas.height - workC.height + (info.pxPerSecY * elapsed);
  if (workC.width != 0) {
    info.canvas.getContext('2d').drawImage(workC, info.x, info.y, workC.width, workC.height);
  }

  if (info.x < ((info.canvas.width - workC.width) * 0.5)) {
    return false;
  }

  return true;
}

class AnimeImage extends HTMLElement {
  constructor() {
    super();
    // info にインスタンス毎に必要なパラメータを入れる。
    this.info = {};
    // devicePixelRatio=1 のディスプレイでも2 をセットした方が綺麗に見えたので、最小で2 とする。
    this.info.dpr = window.devicePixelRatio >= 2 ? window.devicePixelRatio : 2;

    this.getAttribute("animeTime")?.length > 0 ?
      this.info.animeTime = this.getAttribute("animeTime") : this.info.animeTime = DefaultAnimeTime;

    this.getAttribute("expansionRatio")?.length > 0 ?
      this.info.expansionRatio = this.getAttribute("expansionRatio") : this.info.expansionRatio = DefaultExpansionRatio;

    this.getAttribute("style")?.length > 0 ?
      this.style = this.getAttribute("style") : this.style = "display:flex;";

    this.info.src = this.getAttribute("src");
  }

  connectedCallback() {
    this.info.canvas = document.createElement("canvas");
    this.append(this.info.canvas);
    this.info.clientWidth = this.clientWidth;
    startPaint(this.info);

    addEventListener('resize', (e) => {
      this.info.clientWidth = this.clientWidth;
      startPaint(this.info);
    })

    this.addEventListener('click', (e) => {
      startPaint(e.target.parentElement.info);
    })
  }
}
customElements.define("anime-image", AnimeImage);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.