🐧

JS汎用クラスとかを作って貯めていきたい

2022/11/19に公開

記事の意図

汎用的に使えそうなクラスを作って、貯めていこうと思います。
自分のメモ用なので、もし活用される方がいらっしゃれば
その点御理解ください。

クラス

IntersectionObserverで画面内交差検知

JavaScript
class ScrollObserver {
  constructor(els, cb,rootMargin,options) {
    this.els = els;//NodeListを渡す
    const defaultOptions = {
      root: null, //交差対象
      rootMargin: rootMargin, //交差判定境界線
      threshold: 0,//targetのどこで交差判定するか
      once:true
    };
    this.cb = cb;
    this.options = Object.assign(defaultOptions, options); //オブジェクトを合体させる
    this.once = this.options.once;
    this._init();
  }
  
  //初期化
  _init() {
    const callback = function (entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          //画面内に入った時
          this.cb(entry.target, true);
          if (this.once) {
            observer.unobserve(entry.target); //監視を終了する
          }
        } else {
          //画面外に出た時
          this.cb(entry.target, false);
        }
      });
    };
    
    this.io = new IntersectionObserver(callback.bind(this), this.options);
    this.els.forEach(el => this.io.observe(el));
  }

  destroy() {
    this.io.disconnect();//IOの監視を終了する
  }
}

//使い方
//1.コールバック関数を定義する
// const cb = function (el, isIntersecting) {
//   if (isIntersecting) {
//     ここに画面内に入ったら行いたい処理をかく
//   }
// }
//※上記のelにはセレクタではなくElement(entry.target)が渡ることに注意する
//2.インスタンス化する(第一引数にNodeListを渡す)
// const so = new ScrollObserver(document.querySelectorAll('.監視したい要素'), cb, rootMargin,options:あってもなくても良い,{once:false});
//once:falseだと何度も監視をする。デフォルトはtrueで画面内に入った時に一度だけ処理を実行する

テキスト分割アニメーション

JavaScript
class SplitTextAnimation {
  constructor(el) {
    this.el = el;//Elementを渡す
    this.chars = this.el.innerText.trim();
    this.concatStr = "";
    this.el.innerHTML = this._splitText(); //クラスに渡された引数が分割された状態のDOM
    this.animations = [];
    this.chars = '';
    this.transY = "170px";//transformY
    this.outer = document.createElement('div');//対象の親にdivを追加
    this._init();
  }

  //テキストがcharクラスをもつspanで1文字ずつ分割される関数
  _splitText() {
    for (let c of this.chars) {
      c = c.replace(" ", " ");
      this.concatStr += `<span class="char">${c}</span>`;
    }
    return this.concatStr;
  }
 
  //分割したテキストにデフォルトスタイルを付与
  _init() {
    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach(char => {
      char.style.display = 'inline-block';
      // char.style.opacity = 0;
      char.style.transform = `translateY(${this.transY})`;
    })
  }

  //アニメーションの対象をdivで囲みclip-path
  _clip() {
    this.outer.classList.add('js-outer');//囲むdivにクラスを付与
    this.el.parentNode.insertBefore(this.outer, this.el);//対象の親要素の子要素にdivを挿入
    this.outer.appendChild(this.el);//生成したdivの中に対象を入れる
    this.outer.style.clipPath = "polygon(0 0,100% 0,100% 100%,0 100%)";//divより外側をclip-pathで切り取る
  }

  //1文字ずつfadeUpする
  fadeUpText() {
    this._clip();//isIntersectingになったら実行される
    //タイミング制御用オブジェクトを定義
    let timings = {
      easing: "ease-in-out",
      fill: "forwards",
    };
    let x,easing;

    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach((char, i) => {
      x = i / (this.chars.length - 1);//0 ~ 1
      const maxDelay = 170;//delay最大値
      //イージング関数
      function ease(x){
        return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2;
        }
      easing = ease(x);
      timings.delay = easing * maxDelay;
      timings.duration = 550;
      const animation1 = char.animate(
        [{ transform: `translateY(${this.transY}) rotateZ(20deg)` }, { transform: "translateY(0px) rotateZ(0)" }],
        timings
      );
      animation1.cancel();
      this.animations.push(animation1);
  
      const animation2 = char.animate([{ opacity: 0 }, { opacity: 1 }], timings);
      animation2.cancel();
      // this.animations.push(animation2);
    });
    this.animations.forEach((anim) => {
      anim.play();
    });
  }
}

//使い方
//1.インスタンス化
// const ta = new SplitTextAnimation(ここにアニメーションさせたい要素のセレクタを渡す)
//2.アニメーション関数の実行
// ta.fadeUpText()

スクロール量、率、正規化値の取得

JavaScript
//スクロール量、率、正規化した値を取得するクラス
class GetScrollNum {
  constructor() {
    this.scrollY = "";
    this.normalizeScrollY = "";
    this.scrollRate = "";
    this.viewH = document.documentElement.scrollHeight;
    this.totalScrollY = this.viewH - innerHeight;
  }

  getResult() {
    this.scrollY = window.pageYOffset; //スクロール量
    this.normalizeScrollY = this.scrollY / this.totalScrollY; //スクロール量正規化
    this.scrollRate = Math.floor(this.normalizeScrollY * 100); //スクロール率
  }
}

//使い方
//インスタンス化して、スクロールイベント内でgetResultを実行する。
//欲しい値を引っ張り出す

任意のスクロール量で、任意の範囲変化させられる数値を得る

JavaScript
class ScrollFunction {
  constructor(target, scrollMax, rootMargin, ease, min, max) {
    this.target = target; //変化させたいターゲット
    this.scrollMax = scrollMax; //任意のスクロール量の最大値
    this.rootMargin = rootMargin; //画面のどこにtargetが来たら検知開始するか
    this.ease = ease; //イージング関数を入れる
    this.min = min; //任意の範囲の最小値
    this.max = max; //任意の範囲の最大値
    this.scrollY = window.pageYOffset; //画面上部からのスクロール量
    this.targetPos = this.target.getBoundingClientRect().top + this.scrollY; //targetのページ上部からの位置
    this.targetScroll = ""; ///任意の要素がrootMarginの位置に来たときにスクロール量を0としてscrollMaxまで変化する
    this.winH = innerHeight;
    this.resultNormal = 0; //linearでmin~maxの間を変化する変数
    this.resultEase = 0; //イージングを適応してmin~maxまで変化する変数
    this._getResultNum(); //スクロール量を取得して値を更新する
  }

  //正規化
  _norm(v, a, b) {
    return (v - a) / (b - a);
  }

  //線形補完
  _lerp(a, b, t) {
    return a + (b - a) * t;
  }

  _getResultNum() {
    //要素が画面内に最初からいる時(FV内にある時)スクロール量をそのまま正規化に使用する
    if (this.targetPos < this.winH + this.target.clientHeight) {
      window.addEventListener(
        "scroll",
        function () {
          this.scrollY = window.pageYOffset;
          if (this.scrollY > this.scrollMax) {
            this.scrollY = this.scrollMax;
          }
          const normalizeNum = this._norm(this.scrollY, 0, this.scrollMax); //0~1
          const easeNum = this.ease(normalizeNum); //正規化した数値にイージングを効かせる
          this.resultNormal = this._lerp(
            this.min,
            this.max,
            normalizeNum
          ).toFixed(2); //イージングなし
          this.resultEase = this._lerp(this.min, this.max, easeNum).toFixed(2); //イージングが効いた値
        }.bind(this)
      );
    } else {
      window.addEventListener(
        "scroll",
        function () {
          this.scrollY = window.pageYOffset;
          this.targetScroll =
            this.scrollY - this.targetPos + this.winH + this.rootMargin;
          //要素が画面内に入ってからのスクロール量を0~this.scrollMaxに留める
          if (this.targetScroll !== undefined && this.targetScroll < 0) {
            this.targetScroll = 0;
          } else if (
            this.targetScroll !== undefined &&
            this.targetScroll > this.scrollMax
          ) {
            this.targetScroll = this.scrollMax;
          }
          const normalizeNum = this._norm(this.targetScroll, 0, this.scrollMax); //0~1
          const easeNum = this.ease(normalizeNum); //正規化した数値にイージングを効かせる
          this.resultNormal = this._lerp(
            this.min,
            this.max,
            normalizeNum
          ).toFixed(2); //イージングなし
          this.resultEase = this._lerp(this.min, this.max, easeNum).toFixed(2); //イージングが効いた値
        }.bind(this)
      );
    }
  }
}

//使い方
//target(第一引数)にはElementを入れる = document.getElementByID('任意の要素');
//使いたいイージング関数を変数に格納して用意
//スクロールイベント外でインスタンス化
//スクロールイベント内で欲しい値を取得(resultEase or resultNormal)
//targetの変更したいスタイルに取得した数値を適応する
//--------------------------------------------------------

テキスト分割

JavaScript
//classNameに任意のクラス名を渡すことで、テキスト分割時のspanのクラスを指定できる
//クラスは指定しなくても可
class SplitText{
  constructor(targetClassName,generateClassName) {
    this.els = document.querySelectorAll(targetClassName);//NodeListを渡す
    if (generateClassName) {
      this.generateClassName = generateClassName;
    }
    if (this.els) {
      this.els.forEach((el) => {
        this.chars = el.innerText.trim();
        this.concatStr = "";
        el.innerHTML = this._splitText();
      })
    }
  }
  //テキストが[this.classnme]クラスをもつspanで1文字ずつ分割される関数
  _splitText() {
    for (let c of this.chars) {
      c = c.replace(" ", "&nbsp;");
      if (this.generateClassName) {
        this.concatStr += `<span class="${this.generateClassName}">${c}</span>`;
      } else {
        this.concatStr += `<span>${c}</span>`;
      }
    }
    return this.concatStr;
  }
}

//targetClassName:テキスト分割したい対象
//generateClassName:分割したspanに付与するクラス(任意)

ホバーした時のマウス座標を取得して、カーソルに吸い付く動き

JavaScript

class StickAnime {
  constructor(els,range) {
    this.els = els;
    if (range !== undefined) {
    this.range = range;
    }
    this.mouseX = 0;//マウス座標
    this.mouseY = 0;
    this.normX = 0;//マウス座標を-1~+1の数値へ変換
    this.normY = 0;
    this.getHoverMousePos();
  }

  //ホバーした時のマウス座標と、それを−1~1へ変換した数値を取得
  getHoverMousePos() {
    this.els.forEach((el) => {
      let elW = el.clientWidth;
      let elH = el.clientHeight;
      let elPosX, elPosY;
      window.addEventListener("resize", function () {
        elW = el.clientWidth;
        elH = el.clientHeight;
      });
      //ホバーした時に要素の位置を取得
      el.addEventListener("mouseover", function () {
        elPosX = el.getBoundingClientRect().left;
        elPosY = el.getBoundingClientRect().top;
      });


      el.addEventListener("mousemove", function (e) {
        //ホバーしているときにマウス座標を取得
        this.mouseX = e.clientX;
        this.mouseY = e.clientY;

        this.normX = (((this.mouseX - elPosX) / elW - 0.5) * 2).toFixed(2); //-1~1
        this.normY = (((this.mouseY - elPosY) / elH - 0.5) * 2).toFixed(2); //-1~1
      }.bind(this));
    });
  }

  //stickTargetに吸い付くアニメーションを付与
  //translateを%指定
  stickyMovePar() {
    this.els.forEach((el) => {
      const target = el.querySelector(".stickTarget");
      el.addEventListener("mousemove", function () {
        target.setAttribute(
          "style",
          `transform:translate(${this.normX * 10}%,${this.normY * 80}%);`//Y座標の方が動きが小さくなるため適宜調整
        );
      }.bind(this));
      //mouseoutでリセット
      el.addEventListener("mouseout", function () {
        target.setAttribute("style", `transform:translate(0%,0%)`);
        this.mouseX = 0;
        this.mouseY = 0;
        this.normX = 0;
        this.normY = 0;
      });
    });
  }
  //translateをpx指定
  stickyMovePx() {
    this.els.forEach((el) => {
      const target = el.querySelector(".stickTarget");
      let transX = (this.normX * this.range).toFixed(2);
      let transY = (this.normY * this.range).toFixed(2);
      el.addEventListener("mousemove", function () {
      transX = (this.normX * this.range).toFixed(2);
      transY = (this.normY * this.range).toFixed(2);
        target.setAttribute(
          "style",
          `transform:translate(${transX}px,${transY}px);`
          //上下[this.range]px四方の範囲で追従する
        );
      }.bind(this));
      //mouseoutでリセット
      el.addEventListener("mouseout", function () {
        target.setAttribute("style", `transform:translate(0%,0%)`);
        this.mouseX = 0;
        this.mouseY = 0;
        this.normX = 0;
        this.normY = 0;
      });
    });
  }
}

//できること
//任意のクラスを持つ要素にホバーした時にカーソル座標の取得、また座標の正規化(0~1へ変換)
//任意のクラスを持つ要素の中にある「stickTarge」クラスを持つ要素のtranslateを正規化したカーソル座標と連動させて、吸い付く動きをつける。
//任意のクラスを持つ要素は複数でも可能。

//使い方
//HTML側で、ホバーした時にカーソル座標を取得したい要素を用意
//吸い付くテキストを生成したい場合は、上記の中に[stickTarget]クラスを持つ要素を作る
//引数にNodeListと、px指定で移動させたい時は、要素を追従させたい範囲を渡してインスタンス化
//stickyMovePar(%指定)stickyMovePx(px指定、追従範囲指定可能)かを実行。
//cssで任意のtransitionを付与

const sa = new StickAnime(document.querySelectorAll(".wrapper"),50);
sa.stickyMovePx();
// const sa = new StickAnime(document.querySelectorAll(".wrapper"));
// sa.stickyMovePar();

車窓タイプの画像パララックスを実装するためのクラス

JavaScript

class Parallax{
  constructor(trigger,speed) {
    this.trigger = trigger;
    this.triggerEls = document.querySelectorAll(this.trigger);//(.js-parallax-trigger)
    this.triggerEls.forEach(triggerEl => {
      //js-parallax-triggerとimgにデフォルトスタイルを当てる
      triggerEl.setAttribute('style', 'position:relative;overflow:hidden');
      const targetImg = triggerEl.querySelector('img');
      targetImg.setAttribute('style', 'height:100%;');
      this.target = triggerEl.querySelector('.js-parallax-target');
      this.target.setAttribute('style', 'width:100%;height:200%;position:absolute;top:0%;left:0;');
    });
    this.speed = speed;//パララックス速度調整用変数
    this.transY = 0;
    this.triggerPosY = 0;//要素のページ上部からの距離からinnerHeighを引いた数値
    this.scrollY = window.scrollY;//スクロール量
    this.triggerArray = [];//js-parallax-trigger格納用配列
    this.targetArray = [];//js-parallax-target格納用配列
    this.getScrollNum = () => {
      this.scrollY = window.scrollY + window.innerHeight;//window下部からのスクロール量を取得
    if (this.targetArray.length !== 0) {
      this.targetArray.forEach((targetEl) => {
        this.transY = (-(this.scrollY - this.triggerPosY) * speed).toFixed(2);//ターゲットが画面内に入ってきた時のスクロール量を0として、translateの適応するように、数値を調整
        targetEl.style.transform = `translateY(${this.transY}px)`;
      });
    }
    }
    new ScrollObserver(this.triggerEls, this._cb, '0%',{once:false});
  }

  _cb = (el, isIntersecting) => {
    if (isIntersecting) {
      // 画面内の時の処理
      this.triggerArray.push(el);
      this.triggerArray.forEach(triggerEl => {
        this.triggerPosY = triggerEl.getBoundingClientRect().top + window.scrollY;
        this.target = triggerEl.querySelector('.js-parallax-target');
        this.targetArray.push(this.target);
      });
      this.getScrollNum();//ページ読み込み時に一度実行
      window.addEventListener('scroll', this.getScrollNum.bind(this));
    } else {
      //画面外の時の処理
      this.triggerArray.splice();//画面外にでた要素を配列から削除
      this.targetArray.splice();//画面外にでた要素を配列から削除
      window.removeEventListener('scroll',this.getScrollNum.bind(this));
    }
  }
}

const pi = new Parallax('.js-parallax-trigger',0.1);
const pi2 = new Parallax('.js-parallax-trigger2',0.1);
const pi3 = new Parallax('.js-parallax-trigger3',0.1);

//★使い方
//必要なDOM構造は以下
/* <div class="js-parallax-trigger">
      <div class="js-parallax-target">
        <img src="images/image1.jpg" alt="">
      </div>
</div> */
//第一引数 : 画面内に入ったらパララックスを開始して欲しい要素のクラス名を文字列で与える
//第二引数 : パララックスの速度(0.4より大きいと早すぎるので適宜調整)

//★出来ること
//監視対象要素が画面内に入ったら、パララックスを開始
//画面外にでたらパララックス終了(監視は継続)
//ページ際読み込み時もパララックス位置を維持

//★注意点
//一つのインスタンスでできるのは、画面内に一つの監視対象の時、もしくは監視対象が複数の時は全て完全に横並びである必要がある。
//画面内に複数の監視対象があり、位置が異なる場合には、複数のインスタンス化が必要。

シェーダー対応のPlaneを生成するだけのクラス(Three.js)

JavaScript
import * as THREE from 'three';
import vertex from '../shader/vertex.glsl';
import fragment from '../shader/fragment.glsl';

//シェーダー対応のPlaneを生成するクラス
class CreatePlane {
  constructor(container) {
    this.container = container; //レンダリング領域
    if (!this.container) return; //定義されていなければreturn
    this.setup();
    this.render();
  }

  setup() {
    // リサイズ(負荷軽減のためリサイズが完了してから発火する)
    let timeoutId = 0;
    window.addEventListener('resize', () => {
      if (timeoutId) clearTimeout(timeoutId);
      timeoutId = setTimeout(this.onWindowResize.bind(this), 200);
    });
    
    //シーンを定義
    this.scene = new THREE.Scene();

    //カメラを定義
    this.camera = new THREE.PerspectiveCamera(40, this.viewport.aspectRatio, 0.1, 1000);
    this.camera.position.set(0, 0, 3);

    //レンダラーを定義
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
      // alpha: true,
    });
    // this.renderer.setClearColor('#EBF0F2', 1);
    this.renderer.setSize(this.viewport.width, this.viewport.height);
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.container.appendChild(this.renderer.domElement); //body直下にcanvasを追加

    //ジオメトリを定義
    this.geometry = new THREE.PlaneGeometry(1, 1, 32, 32);

    //uniformsを定義
    let uniforms = {};

    //マテリアルを定義
    this.material = new THREE.ShaderMaterial({
      // uniforms,
      vertexShader: vertex,
      fragmentShader: fragment,
    });

    //Planeを生成
    this.plane = new THREE.Mesh(this.geometry, this.material);
    this.scene.add(this.plane);
  }

  //レンダリング
  render() {
    this.renderer.render(this.scene, this.camera);
  }

  //viewportサイズとアスペクト比を取得
  get viewport() {
    let width = this.container.clientWidth;
    let height = this.container.clientHeight;
    let aspectRatio = width / height;
    return {
      width,
      height,
      aspectRatio,
    };
  }

  //リサイズ対応
  onWindowResize() {
    this.camera.aspect = this.viewport.aspectRatio;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(this.viewport.width, this.viewport.height);
    this.renderer.render(this.scene, this.camera);
  }
}

const container = document.querySelector('.wrapper');
const planeCanvas = new CreatePlane(container);
vertex.glsl
varying vec2 vUv;

void main() {
  vUv = uv;
  vec3 pos = position;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
fragment.glsl
varying vec2 vUv;

void main() {
  gl_FragColor =vec4(vUv,1.,1.);
}

描画結果

活用例

ScrollObserver + ScrollFunction

JavaScript
//活用例
//ID:target1,target2をつけた要素を用意
//target1は500pxスクロールする間にtransformX 300px => 1000px
//target2は500pxスクロールする間にtransformX 100px => 500px,scale(1) => 2
//要素が画面内にある時のみ処理を実行
//fps制御,スクロールイベントではなくrAFで制御,rAFはスクロールしている時のみループ

//requestAnimationFrameをリセットするためのIDを格納
let frameId,frameId2;
//直前のスクロール位置を格納
let lastPosition = -1;
//フレームレート設定
const framesPerSecond = 60;
const interval = Math.floor(1000 / framesPerSecond);
const startTime = performance.now();
let previousTime = startTime;
let currentTime = 0;
let elapsed = 0;

//ScrollFunctionの引数定義
const target1 = document.getElementById('target1');
const target2 = document.getElementById('target2');
const scrollMax = 500;
const rootMargin = -200;//画面下から100pxの位置に要素が来たらアニメーション開始
const ease = function easeInOutBack(x) {
  const c1 = 1.70158;
  const c2 = c1 * 1.525;

  return x < 0.5
    ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2
    : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
};


const cb = function (el, isIntersecting) {
  const sf = new ScrollFunction(target1, 500,rootMargin, ease, 300, 1000);
  if (isIntersecting) {
    //要素が画面内の時の処理
    const raf = (timestamp) => {
      //スクロール位置に変化があった時のみループを実行
      if (lastPosition === sf.scrollY) {
        frameId = requestAnimationFrame(raf);
        return;
      }
      lastPosition = sf.scrollY;

      //fps制御
      currentTime = timestamp;
      if (currentTime) {
        elapsed = currentTime - previousTime;
      }
      if (elapsed <= interval) {
        frameId = requestAnimationFrame(raf);
        return;
      }
      previousTime = currentTime - (elapsed % interval);

      //ここからアニメーションさせたいコード書く
      target1.setAttribute(
        "style",
        `transform:translateX(${sf.resultEase}px)`
      );
      frameId = requestAnimationFrame(raf);
    };
    raf();
  } else {
    elapsed = 0;
    cancelAnimationFrame(frameId);
  }
};

const cb2 = function (el, isIntersecting) {
  const sf2 = new ScrollFunction(target2, 300, rootMargin, ease, 100, 500);
  const sf3 = new ScrollFunction(target2, 300, rootMargin, ease, 1, 3);
  if (isIntersecting) {
    //要素が画面内の時の処理
    const raf = (timestamp) => {
      //スクロール位置に変化があった時のみループを実行
      if (lastPosition === sf2.scrollY) {
        frameId2 = requestAnimationFrame(raf);
        return;
      }
      lastPosition = sf2.scrollY;

      //fps制御
      currentTime = timestamp;
      if (currentTime) {
        elapsed = currentTime - previousTime;
      }
      if (elapsed <= interval) {
        frameId2 = requestAnimationFrame(raf);
        return;
      }
      previousTime = currentTime - (elapsed % interval);

      //ここからアニメーションさせたいコード書く
      target2.setAttribute(
        "style",
        `transform:translateX(${sf2.resultNormal}px) scale(${sf3.resultNormal})`
      );
      frameId2 = requestAnimationFrame(raf);
    };
    raf();
  } else {
    elapsed = 0;
    cancelAnimationFrame(frameId2);
  }
};

const so = new ScrollObserver(
  document.querySelectorAll(".target"),
  cb,
  `0px 0px ${rootMargin}px 0px`,
  { once: false }
);
const so2 = new ScrollObserver(
  document.querySelectorAll(".target2"),
  cb2,
  `0px 0px ${rootMargin}px 0px`,
  { once: false }
);

課題

  • ScrollObserberのcbで画面内にある時はrAFを回して、画面外でrAFを止める処理をしているが
    複数の要素を同時に監視した時、一つの要素が最初から画面内にあってその他が画面外にあるという条件下では
    isIntersectingがtrue->falseとなるため、最初から画面内の要素のcbが実行されずにcancelされてしまう。
  • 上記理由より、動かしたい要素が複数ある場合は、別々にScrollObserverで監視する必要があり
    その度に、cbとcancel用のid格納変数を用意する必要がある。

Discussion