milvinae

web & product design.

  • どうしたら写真をキレイにバラバラに配置できるのか?

    どうしたら写真をキレイにバラバラに配置できるのか?
    前回、写真をばらまくアニメーションを作ったものの、なぜか写真が重なることが多くてキレイなランダム感が出ないことがありました。
    位置決めに使っていたanime.random関数に偏りが出やすいのかもしれません。

    今回は写真をうまくばらまくという目的のために、”ランダムとは何か?”を掘り下げていこうと思います。



    …と思っていましたが、あまりにランダムの海は広く深く底が見えないことがわかりました。
    ランダム-Wikipedia
    乱数生成-Wikipedia
    擬似乱数-Wikipedia
    暗号論的擬似乱数生成器-Wikipedia
    カオス理論-Wikipedia


    サイコロですらカオス理論的側面により初期値鋭敏性が云々…
    心を落ち着けるためにモンハン起動しちゃいましたよ。




    ちょっと理屈を理解して完全なる乱数を目指すことは論文レベルになってしまうみたいです。
    バラバラに見えるように数枚の写真を配置するだけでこのレベルはやりすぎでした。


    目標を変えて、”一見ランダムに見えるという乱数をデザインする”という形を目指しましょう。

    まず現状の問題を把握をしましょう。

    前述のとおり、何度も試行しているとどうも写真が重なる確率が高いように感じられました。
    位置決めにはanime.random関数しかかかわっていないので、重なる原因はanime.randomにあるように思えます。

    anime.random内部では実質Math.random関数が支配しており、これが偏ることがあったりするのでしょうか?
    Math.random()

    MDNによると、Math.random関数は実装された時点で乱数の種が固定されてしまっている、とのことでした。
    これはつまり乱数の現れ方がある程度決まってしまっていることを意味しています。
    だからといって乱数があらわれる周期が不十分、ということを意味しません。

    可能性があることは分かりました。
    実際のところ本当に偏っているのでしょうか?

    以下にCodepenで作成したMath.randomのランダム性テストをお見せします。

    See the Pen Untitled by keiyashi (@keiyashi) on CodePen.


    さて、サンプルコードでは5万回試行して、1から100まで(正確には0から99まで)の値の出た回数を表示します。

    出現率の低いものと高いもので100回以上の差があるものもあります。
    これは偏っている、といってもいいのでしょうか?
    この時点では私は偏っていると考えていました。


    次に、”従来にない長周期, 高次元均等分布”な擬似乱数生成器であるメルセンヌ・ツイスターでランダムしてみます。
    http://www.math.sci.hiroshima-u.ac.jp/m-mat/MT/what-is-mt.html

    See the Pen is Math.random true random? by keiyashi (@keiyashi) on CodePen.


    あれ?
    意外と…
    Math.randomと同じくらい差がある…?

    それぞれの方法を数回試行してみた感じ、どちらも散り方は似たようなものでした。
    ということは、Math.randomは偏っていない&ランダムを使って写真を配置した場合複数枚が重なってしまうことは大いにあり得る、ということになるわけですね。

    ※Math.randomは実装方法が違うものがあり、(暗号以外の用途では)十分に使用に耐えうるXorShift乱数発生器が使われている場合があるそうです。
     私が使用しているブラウザはどうやら偏りがないMath.randomを使っていた可能性が高いです。



    では、ランダムっぽい配置にする位置決め方法をデザインしていきます。
    【要件】
    1.1度に表示する写真は6枚
    2.写真を貼り付けるボードの上下左右に一様に散る
    3.ある程度の重なりがある

    つまり、6枚の写真のうち4枚は四隅方向に散らばり、残り2枚がいい感じに散ってくれればOK、という感じです。
    四隅のざっくりとした位置を決めておいてほんの少しランダムで動かすこともできますが、あんまりキレイな感じがないので気が進みません。
    なるべくキレ~な形にしたいです。

    X・Y方向にわけて、それぞれ上下・左右に寄りやすい確率を考えると、三角関数が使えそうな気がします。
    0→360度変化したときのsinΘをX成分だけ・Y成分だけ取り出すと、1・ー1側に偏ります。
    Θを0~359度の中から無作為に6つ取り出せば、うち2つは1寄りに、うち2つは‐1寄りに、うち2枚は0あたりに散らばりやすいはずです。

    方針はいい感じです。

    var value = Math.floor((Math.sin(Math.random() * 360) + 1) / 2 * 100);

    おぉ~!
    いい感じに偏ってる気がしますね。
    中心付近もバラツキがあっていい結果になりそうな気がしますね。
    そもそもSinで大きい偏りがあるので、Randomをつかっても影響は少ないでしょう。

    ではこれを使って写真を配置してみます。


    あれぇ?
    なんだか結構偏っちゃいましたねぇ…。
    理屈では行けそうな気がしたんです。

    もう画像枚数で360度を均等割りして割り振っちゃいましょう。
    これなら確実に要件通りになるはずです。
    初期値だけランダムにすれば同じようなばらけ方はぱっと見わからないようになりますね。


    時々固まっちゃうこともありますが、印象的には以前よりばらけてる感じになりました。

    今回はこれで良しとしましょう!

  • 変わったGalleryを力技で作る

    変わったGalleryを力技で作る

    横並びのGallery、グリッド表示のGallery…
    ……もう飽き飽きですわ!

    そんなあなたにおすすめしたい、自分で張った写真のようなGalleryの作り方を紹介します。
    まずは達成したい目標を明らかにしましょう。

    <目標>
    ・任意の範囲内に画像をばらまく感じのギャラリーを作りたい
    ・ばらまくアニメーションがあると尚良し
    ・Javascriptで画像要素を生成して動的に処理したい


    あんまり複雑な処理は必要なさそうですね。
    結果から見てみましょう。

    ※Codepenで使用している画像はすべてPhotockさんのフリー素材です。
    https://photock.jp/

    See the Pen picture board by keiyashi (@keiyashi) on CodePen.


    htmlはBackboardだけの超シンプル構成です。

    画像たちはJavascriptで生成するのですが、具体的にはFigure要素(picture-frameクラス)の中にimg要素が入れる感じです。
    次はそれらのcss(scss)を記しましょう。
    .backboard{
      width: 90dvw;
      height: 90dvh;
      position:relative;
      left:5dvw;
      top:5dvh;
      background-image:  url("https://raw.githubusercontent.com/keiyashi/codepen-example/refs/heads/main/CorkBoard.jpg");
      background-repeat: no-repeat;
      background-position: center;
      background-size: cover;
      padding: 15px;
      box-shadow: 2dvw 2dvw 2dvw rgba(64,64,64,.3);
      border-image-source: linear-gradient(-30deg, rgba(64,64,64,.9) 0%, rgba(128,64,32,.9) 60%);
      border-image-slice: 1;
      border-width: 13px;
      border: outset;
    }
    
    .picture-frame{
      position:absolute;
      width:15dvw;
      aspect-ratio:1.5;
      background-color:white;
      transform-origin:center;
      box-shadow:0px 0px 3px rgba(64, 64, 64, .8);
      img{
        position:relative;
        width:92%;
        height:92%;
        top:4%;
        left:4%;
        color:lightblue;
      }
      
    }


    縦長画像と横長画像を区別して表示したいという欲求はありました。
    でもなんかうまくいかなくてムシャクシャしたので横長表示で統一してしまいました。
    とにかく画像は横長Width15vwで縦横比はとにかく1.5だ!

    cssに重要な部分は特にないですね。
    グラデーションの枠や影など装飾的要素がいくつか入っています。



    つぎはJavascriptです。

    画像は扱いやすいように”pic-x.webp”という名前で統一、Githubにおいておきます。
    こうすることでJSで画像パスを引っ張りやすくしました。
    すでにフォーマットが違う画像たちを使ってギャラリーを作る場合は、名前を変えて再アップロードするかJS側でどうにか呼び出す方法を自分で考えるか諦めてプラグインを使ってください。

    const picNumber = 17;
    const picIdPrefix = "pic";
    var   gitpass = "https://raw.githubusercontent.com/keiyashi/codepen-example/refs/heads/main/pic-";
    var   fileSuffix = ".webp";
    
    function initialize(){
      var back = document.querySelector(".backboard");
      
      for(i = 0; i < picNumber; i++){
        var picture = document.createElement("figure",{id: picIdPrefix + i}); 
        picture.classList.add("picture-frame");
        var pictureInner = document.createElement("img");
        pictureInner.src = gitpass + (i+1) + fileSuffix;
        picture.appendChild(pictureInner);
        back.appendChild(picture);
      }
    }
    
    function randomSet(){
      var back = document.querySelector(".backboard");
      var backRect = back.getBoundingClientRect();
      var innerAreaX = backRect.width * 1.2;
      var innerAreaY = backRect.height * 1.2;
      anime.set(".picture-frame", {
        // delay: 1000,
        translateX: function(){return anime.random(0, innerAreaX) + "px";},
        translateY: function(){return anime.random(0, innerAreaY) + "px";},
        rotate: function(){ return anime.random(-180, 180)},
      });
    }
    
    function randomMove(){
      var back = document.querySelector(".backboard");
      var backRect = back.getBoundingClientRect();
      var innerAreaX = backRect.width * 0.8;
      var innerAreaY = backRect.height * 0.8;
      anime({
        targets: ".picture-frame",
        delay: 500,
        duration: function(){ return anime.random(0, 1000)},
        easing: "easeOutSine",
        translateX: function(){return anime.random(0, innerAreaX) + "px";},
        translateY: function(){return anime.random(0, innerAreaY) + "px";},
        rotate: function(){ return anime.random(-180, 180)},
      });
    }
    
    document.addEventListener("DOMContentLoaded", (e) =>{
      initialize();
      randomSet();
      randomMove();
    })

    画像をばらまくアニメーションをつけたいのでanime.jsを使っています。
    initializeで画像要素を生成してBack-boardに入れ込む ⇒ 画像たちの初期位置をSetする ⇒ 画像たちが初期位置からランダムに動く という流れになっています。


    シンプルではありますが画像たちをバラバラに配置することが出来ました。
    こうなってくると、一度に見せる画像を少なくして視認性を上げたり、選択した画像をアップで表示したりなど機能を追加したくなりますね。
    後日、改良方法をアップしたいと思います。




    <注意>
    勘のいい方は画像が偏りがちなことに気付くでしょう。
    これはanime.randomの中で使われているMath.randomが実装依存の擬似乱数であることが原因のようです。
    ※乱数生成の種となるシードが実装時点で決まってしまっているためユーザが変更することが出来ず、生成される乱数には偏りが出るみたいです。
    https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Math/random

    これは見た目上よろしくないので後日記事で解決方法を記載します。

  • 独自スクロールバーをjavascript+scssだけで実装してみる2

    独自スクロールバーをjavascript+scssだけで実装してみる2
    前回、ScrollbarをJavascript+scssで自作しました。
    しかし、あのやり方では1つのページに複数のスクロール領域があるとうまくいきません。
    というのも、querySelectorで1つのscroll-areaにしかアクセスしていませんし対応する変数が1つしかないからです。

    もし複数のスクロール領域を対象にするなら、それぞれに対して位置や長さなどの変数が必要になります。
    さぁて、これからどうしていきましょうか…

    1つ目の方法は、querySelectorAllで全部のframe-area/scroll-areaを拾って、前述の通りその分だけ変数を準備する方法。
    理屈的にはできそうですが、今どのスクロールエリアがスクロールされているのかを総当たりでチェックするような未来が見えます。
    あまりスマートなやり方ではなさそうな感じがしますね。

    2つ目の方法はスクロールクラスを作って、スクロール領域1つ1つに対してクラス実体を持たせる方法。
    クラスに変数を持たせることでスクロール領域ごとの情報を独立保持することができます。
    処理も独立しているのでなんとなくうまくいきそうな気がします。
    2つ目の方法で進めてみましょう。


    // props
    var thumbThickness = 8;
    var trackLengRatio = 0.8;
    var thumbMinLength = 20;
    var trackDistanceFromEdge = 5;
    
    class customScroller{
      // element
      frameArea ;
      scrollArea ;
      trackH ;
      thumbH ;
      trackV ;
      thumbV ;
      // variable
      frameRect;        // current frame rect info.
      scrollRect;       // current scroll rect info.
      thumbVTopEdge;    // client Y about vertical thumb when mouse down.  
      thumbHLeftEdge;   // client X about horizontal thumb when mouse down.
      movableLengH;     // length that scroll-area can move in horizontal.  
      movableLengV;     // length that scroll-area can move in vertical.
      movableBarH;      // length that horizontal thumb on horizontal track.
      movableBarV;      // length that vertical thumb on vertical track.
      // flags
      activeH;          // is horizontal thumb draggin?
      activeV;          // is vertical thumb draggin?
    
      constructor(el){
        this.frameArea  = el;
        this.scrollArea = el.querySelector(".scroll-area");
    
        this.createHTML();
        this.initialize();
        this.updateTrack();
      }
    
      // initializer
      initialize(classThis){
     
        this.trackH      = this.frameArea.querySelector('.scroll-track-h');
        this.thumbH      = this.frameArea.querySelector('.scroll-thumb-h');
        this.trackV      = this.frameArea.querySelector('.scroll-track-v');
        this.thumbV      = this.frameArea.querySelector('.scroll-thumb-v');
        
        this.thumbH.addEventListener("mousedown",   this.holdThumbH, { passive: true });
        this.thumbV.addEventListener("mousedown",   this.holdThumbV, { passive: true });
        this.frameArea.addEventListener("mousemove", this.moveThumb, { passive: false }); 
        // "passive: false" means callback has preventDefault function
        this.frameArea.addEventListener("mouseup",   this.releaseThumb);
        this.scrollArea.addEventListener("selectstart",   function(e) {
          if(this.activeH || this.activeV) 
            e.preventDefault();
        });
        
        this.frameArea.addEventListener("scroll",  this.scrollingArea);
        this.frameArea.addEventListener("transitionend", this.updateTrack);
        this.trackH.addEventListener("click",       this.clickTrackH, { passive: false });
        this.trackV.addEventListener("click",       this.clickTrackV, { passive: false });
        
        window.addEventListener('resize', this.updateTrack);
        this.frameResizingObserver(this);
        
        this.thumbHLeftEdge = 0;
        this.thumbVTopEdge  = 0;
        this.movableLengH   = 0;
        this.movableLengV   = 0;
        this.movableBarH    = 0;
        this.movableBarV    = 0;
        this.activeH        = false;
        this.activeV        = false;
      }
      
      frameResizingObserver(){
        const observer = new MutationObserver(this.updateTrack);
        observer.observe(this.frameArea, {attributes: true, attributeFilter: ["style"]});
      }
    
      // update variable 
      // recalled by window resize or frame resize
      updateTrack(){
        // if(this.frameArea.style.opacity < 1) 
        //   return ;
    
        this.frameRect  = this.frameArea.getBoundingClientRect();
        this.scrollRect = this.scrollArea.getBoundingClientRect();
    
        var goHorizon  = false;
        var goVertical = false;
        if(this.frameRect.width >= this.scrollRect.width){
          this.trackH.style.visibility = "hidden";
          this.thumbH.style.visibility = "hidden";
        }else{
          this.trackH.style.visibility = "visible";
          this.thumbH.style.visibility = "visible";
          goHorizon = true;
        }
        
        if(this.frameRect.height >= this.scrollRect.height){
          this.trackV.style.visibility = "hidden"; 
          this.thumbV.style.visibility = "hidden"; 
        }else{
          this.trackV.style.visibility = "visible";
          this.thumbV.style.visibility = "visible";
          goVertical = true;
        }
          
        var widRatio = (this.frameRect.width * trackLengRatio)  / this.scrollRect.width;
        var hgtRatio = (this.frameRect.height * trackLengRatio) / this.scrollRect.height;
    
        // horizontal scrollbar 
        if(goHorizon == true){
          this.trackH.style.width  = (this.frameRect.width * trackLengRatio) + "px";
          this.trackH.style.height = thumbThickness + "px";
          this.trackH.style.left   = (this.frameRect.x + this.frameRect.width * (1 - trackLengRatio) / 2)  + "px";
          this.trackH.style.bottom = (window.innerHeight - this.frameRect.bottom + thumbThickness + trackDistanceFromEdge) + "px";
          
          var trackH_Rect = this.trackH.getBoundingClientRect();
          var thumbHWidth = trackH_Rect.width  * widRatio;
          if(thumbHWidth < thumbMinLength) thumbHWidth = thumbMinLength;
          this.thumbH.style.width  = thumbHWidth + "px";
          this.thumbH.style.height = trackH_Rect.height + "px";
          this.thumbH.style.top = "0px";
          this.thumbH.style.borderRadius = thumbThickness / 2 + "px";
          
          this.thumbLeftEdge = this.thumbH.getBoundingClientRect().x; 
          this.movableLengH  = this.scrollRect.width - this.frameRect.width;
          this.movableBarH   = (this.frameRect.width * trackLengRatio) - thumbHWidth;
        }
    
        // vertical scrollbar
        if(goVertical){
          this.trackV.style.width  = thumbThickness + "px";
          this.trackV.style.height = (this.frameRect.height * trackLengRatio) + "px";
          this.trackV.style.right  = (window.innerWidth - this.frameRect.right + (thumbThickness + trackDistanceFromEdge))  + "px";
          this.trackV.style.top    = (this.frameRect.y + this.frameRect.height * (1 - trackLengRatio) / 2) + "px";
          
          var trackV_Rect = this.trackV.getBoundingClientRect();
          this.thumbV.style.width  = trackV_Rect.width  + "px";
          var thumbVHeight = trackV_Rect.height * hgtRatio;
          if(thumbVHeight < thumbMinLength) thumbVHeight = thumbMinLength;
          this.thumbV.style.height = thumbVHeight + "px";
          this.thumbV.style.top = "0px";
          this.thumbV.style.borderRadius = thumbThickness / 2 + "px";
          
          this.thumbTopEdge = this.thumbV.getBoundingClientRect().y; 
          this.movableLengV = this.scrollRect.height - this.frameRect.height;
          this.movableBarV  = (this.frameRect.height * trackLengRatio) - thumbVHeight;
        }
      }
    
      createHTML(){
        // Horizontal
        const horizontalTrack = document.createElement("div");
        horizontalTrack.classList.add("scroll-track-h");
        const horizontalThumb = document.createElement("div");
        horizontalThumb.classList.add("scroll-thumb-h");
        horizontalTrack.appendChild(horizontalThumb);
        this.frameArea.appendChild(horizontalTrack);
        
        // vertical
        const verticalTrack = document.createElement("div");
        verticalTrack.classList.add("scroll-track-v");
        const verticalThumb = document.createElement("div");
        verticalThumb.classList.add("scroll-thumb-v");
        verticalTrack.appendChild(verticalThumb);
        this.frameArea.appendChild(verticalTrack);
      };
    
      // event 
      // mouse down on horizontal thumb 
      holdThumbH(e){
        console.log("mouseDown");
        this.activeH = true;
        this.thumbHLeftEdge = e.offsetX;
      } 
    
      // mouse down on vertical thumb 
      holdThumbV(e){
        console.log("mouseDown");
        this.activeV = true;
        this.thumbVTopEdge  = e.offsetY;
      }
    
      // mouse move on document
      // because dont move only on thumb 
      moveThumb(e){ 
        if(!this.activeH && !this.activeV){ 
          return; }
        e.preventDefault();
        
        if(this.activeH){
          e = e.clientX;
          console.log("horizontal: " + e);
          // it use css style "translate".
          // because need diff length from element initial pos.
          var curThumbLeftX = e - this.trackH.getBoundingClientRect().x - this.thumbHLeftEdge;
          var trackRatio = curThumbLeftX / this.movableBarH;
          var actualMoveX = this.movableLengH * trackRatio;
          if(curThumbLeftX < 0) 
            curThumbLeftX = 0;
          else if(curThumbLeftX > this.movableBarH) 
            curThumbLeftX = this.movableBarH;
          this.thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";
          this.frameArea.scrollLeft = actualMoveX;
        }
        if(this.activeV){
          e = e.clientY;
          console.log("vertical: " + e);
          var curThumbTopY = e - this.trackV.getBoundingClientRect().y - this.thumbVTopEdge;
          var trackRatio = curThumbTopY / this.movableBarV;
          var actualMoveY = this.movableLengV * trackRatio;
          if(curThumbTopY < 0) 
            curThumbTopY = 0;
          else if(curThumbTopY > this.movableBarV) 
            curThumbTopY = this.movableBarV;
          this.thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
          this.frameArea.scrollTop = actualMoveY;
        }
      }
    
      releaseThumb(e){
        console.log("mouseUp");
    
        // wait for move sequence.
        requestAnimationFrame(function() {
          document.body.style.overflow = "";
          e.stopPropagation();
          this.activeV = false;
          this.activeH = false;
        }, 0);
      }
    
      scrollingArea(){
        console.log("scrolled");
        if(this.activeH || this.activeH) {
          return;  
        }
        var scrolltop  = this.frameArea.scrollTop;
        var scrollleft = this.frameArea.scrollLeft;
        var scrollRatioH = scrollleft / this.movableLengH;
        var scrollRatioV = scrolltop  / this.movableLengV;
        var curThumbTopY = this.movableBarV * scrollRatioV;
        var curThumbLeftX= this.movableBarH * scrollRatioH;
        
        this.thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
        this.thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";
        
      }
    
      // click in trackbar
      clickTrackH(e){
        if(this.activeH) {return;} // reject mousemove and release
        
        console.log("clicked Horizontal");
        e.preventDefault();
        
        var thumbNewX = e.offsetX - this.thumbH.scrollWidth / 2;
        if(thumbNewX < 0)
          thumbNewX = 0;
        else if(thumbNewX > this.movableBarH)
          thumbNewX = this.movableBarH;
        this.thumbH.style.transform = "translateX(" + thumbNewX + "px)";
        
        var trackRatio = thumbNewX / this.movableBarH;
        var actualMoveX = this.movableLengH * trackRatio;
        this.frameArea.scrollLeft = actualMoveX; 
      }
    
      clickTrackV(e){
        if(this.activeV) { return ;}
        console.log("clicked vertical");
        e.preventDefault();
        
        var thumbNewY = e.offsetY - this.thumbV.scrollHeight / 2;
          if(thumbNewY < 0)
          thumbNewY = 0;
        else if(thumbNewY > this.movableBarV)
          thumbNewY = this.movableBarV;
        this.thumbV.style.transform = "translateY(" + thumbNewY + "px)";
        
        var trackRatio = thumbNewY / this.movableBarV;
        var actualMoveY = this.movableLengV * trackRatio;
        this.frameArea.scrollTop = actualMoveY;
      }
    
    }
    
    // called after Dom loaded
    document.addEventListener("DOMContentLoaded", (e) => {
      var frames = document.querySelectorAll(".frame-area");
      for( i = 0; i < frames.length; i++){
        var scroller = new customScroller(frames[i]);
      }
    });
    

    class化しました。
    変数と関数群をClassの中に入れて、DomContentLoadedでリッスンしてたのをコンストラクタに移しました。

    理屈で行けば行けそうな気がします。






    うまくいきませんね。
    デバッグで追跡すると、this.frameAreaが見つからない(undefined)とのことでした。
    私の実行環境ではthisがMutitationObserverになっていました。

    重要なポイントなのですが、javascriptのthisとCのthisは挙動が違います。
    C経験者的にはthisはクラス内部にアクセスするものだと思ってしまいますが、javascriptのthisは呼び出された元の内部にアクセスする仕様があります。
    https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/this

    今回の実装でにはMutitationObserverでリサイズを監視しています。
    リサイズが検知されてObserverが登録された関数を実行すると、関数内のthisが呼び出したObserverとして処理されてしまうというわけです。

    ふーむ…。
    これは困りましたね。
    どうしたらいいでしょうか?

    順当に考えれば、クラス実体を変数にして保持しておけば、thisを使わずにクラス実体にアクセスできるはずです。
    クラスに実体を保持する変数を追加し、コンストラクタで自身を代入してみます。

    this._this_ = this;






    …ダメでしたね。
    そもそも変数で保持してもそれを呼び出すにはまたthisが必要になるからです。


    ならば、イベントリスナーに登録するときに実体も一緒に渡してはどうでしょうか。
    つまりこういうことです。
    this.XXXXX.addEventListener("some-event",   function(e){this.someFunction(e, this) });

    ・・・・

    結局、呼び出したときthisが処理されるので、呼び出し元にsomeFunctionが無くNGでした。

    では一度変数に入れなおしてから呼び出してみましょう。
    var _this_ = this;
    this.XXXXX.addEventListener("some-event",   function(e){_this_.someFunction(e, _this_) });


    うまくいきましたね!
    _this_に実体を入れて確定しているため、望ましく処理が実行されました。
    イベントで呼び出される処理のthisを書き換えていきます。

    では書き直したコードを下に記載します。

    
    // props
    var thumbThickness = 8;
    var trackLengRatio = 0.8;
    var thumbMinLength = 20;
    var trackDistanceFromEdge = 5;
    
    class customScroller{
      // element
      frameArea ;
      scrollArea ;
      trackH ;
      thumbH ;
      trackV ;
      thumbV ;
      // variable
      frameRect;        // current frame rect info.
      scrollRect;       // current scroll rect info.
      thumbVTopEdge;    // client Y about vertical thumb when mouse down.  
      thumbHLeftEdge;   // client X about horizontal thumb when mouse down.
      movableLengH;     // length that scroll-area can move in horizontal.  
      movableLengV;     // length that scroll-area can move in vertical.
      movableBarH;      // length that horizontal thumb on horizontal track.
      movableBarV;      // length that vertical thumb on vertical track.
      // flags
      activeH;          // is horizontal thumb draggin?
      activeV;          // is vertical thumb draggin?
    
      constructor(el){
        this.frameArea  = el;
        this.scrollArea = el.querySelector(".scroll-area");
    
        this.createHTML();
        this.initialize(this);
        this.updateTrack(this);
      }
    
      // initializer
      initialize(classThis){
     
        this.trackH      = this.frameArea.querySelector('.scroll-track-h');
        this.thumbH      = this.frameArea.querySelector('.scroll-thumb-h');
        this.trackV      = this.frameArea.querySelector('.scroll-track-v');
        this.thumbV      = this.frameArea.querySelector('.scroll-thumb-v');
        
        this.thumbH.addEventListener("mousedown",   function(e){classThis.holdThumbH(e, classThis) }, { passive: true });
        this.thumbV.addEventListener("mousedown",   function(e){classThis.holdThumbV(e, classThis) }, { passive: true });
        this.frameArea.addEventListener("mousemove", function(e){classThis.moveThumb(e, classThis) }, { passive: false }); 
        // "passive: false" means callback has preventDefault function
        this.frameArea.addEventListener("mouseup",   function(e){classThis.releaseThumb(e, classThis) });
        this.scrollArea.addEventListener("selectstart",   function(e) {
          if(classThis.activeH || classThis.activeV) 
            e.preventDefault();
        });
        
        this.frameArea.addEventListener("scroll",  function(){classThis.scrollingArea(classThis)});
        this.frameArea.addEventListener("transitionend", function(){classThis.updateTrack(classThis)});
        this.trackH.addEventListener("click",       function(e){classThis.clickTrackH(e, classThis) }, { passive: false });
        this.trackV.addEventListener("click",       function(e){classThis.clickTrackV(e, classThis) }, { passive: false });
        
        window.addEventListener('resize', function(e){classThis.updateTrack(classThis) });
        this.frameResizingObserver(this);
        
        this.thumbHLeftEdge = 0;
        this.thumbVTopEdge  = 0;
        this.movableLengH   = 0;
        this.movableLengV   = 0;
        this.movableBarH    = 0;
        this.movableBarV    = 0;
        this.activeH        = false;
        this.activeV        = false;
      }
      
      frameResizingObserver(classThis){
        const observer = new MutationObserver(function(){classThis.updateTrack(classThis)});
        observer.observe(this.frameArea, {attributes: true, attributeFilter: ["style"]});
      }
    
      // update variable 
      // recalled by window resize or frame resize
      updateTrack(_this_){
        // if(_this_.frameArea.style.opacity < 1) 
        //   return ;
    
        _this_.frameRect  = _this_.frameArea.getBoundingClientRect();
        _this_.scrollRect = _this_.scrollArea.getBoundingClientRect();
    
        var goHorizon  = false;
        var goVertical = false;
        if(_this_.frameRect.width >= _this_.scrollRect.width){
          _this_.trackH.style.visibility = "hidden";
          _this_.thumbH.style.visibility = "hidden";
        }else{
          _this_.trackH.style.visibility = "visible";
          _this_.thumbH.style.visibility = "visible";
          goHorizon = true;
        }
        
        if(_this_.frameRect.height >= _this_.scrollRect.height){
          _this_.trackV.style.visibility = "hidden"; 
          _this_.thumbV.style.visibility = "hidden"; 
        }else{
          _this_.trackV.style.visibility = "visible";
          _this_.thumbV.style.visibility = "visible";
          goVertical = true;
        }
          
        var widRatio = (_this_.frameRect.width * trackLengRatio)  / _this_.scrollRect.width;
        var hgtRatio = (_this_.frameRect.height * trackLengRatio) / _this_.scrollRect.height;
    
        // horizontal scrollbar 
        if(goHorizon == true){
          _this_.trackH.style.width  = (_this_.frameRect.width * trackLengRatio) + "px";
          _this_.trackH.style.height = thumbThickness + "px";
          _this_.trackH.style.left   = (_this_.frameRect.x + _this_.frameRect.width * (1 - trackLengRatio) / 2)  + "px";
          _this_.trackH.style.bottom = (window.innerHeight - _this_.frameRect.bottom + thumbThickness + trackDistanceFromEdge) + "px";
          
          var trackH_Rect = _this_.trackH.getBoundingClientRect();
          var thumbHWidth = trackH_Rect.width  * widRatio;
          if(thumbHWidth < thumbMinLength) thumbHWidth = thumbMinLength;
          _this_.thumbH.style.width  = thumbHWidth + "px";
          _this_.thumbH.style.height = trackH_Rect.height + "px";
          _this_.thumbH.style.top = "0px";
          _this_.thumbH.style.borderRadius = thumbThickness / 2 + "px";
          
          _this_.thumbLeftEdge = _this_.thumbH.getBoundingClientRect().x; 
          _this_.movableLengH  = _this_.scrollRect.width - _this_.frameRect.width;
          _this_.movableBarH   = (_this_.frameRect.width * trackLengRatio) - thumbHWidth;
        }
    
        // vertical scrollbar
        if(goVertical){
          _this_.trackV.style.width  = thumbThickness + "px";
          _this_.trackV.style.height = (_this_.frameRect.height * trackLengRatio) + "px";
          _this_.trackV.style.right  = (window.innerWidth - _this_.frameRect.right + (thumbThickness + trackDistanceFromEdge))  + "px";
          _this_.trackV.style.top    = (_this_.frameRect.y + _this_.frameRect.height * (1 - trackLengRatio) / 2) + "px";
          
          var trackV_Rect = _this_.trackV.getBoundingClientRect();
          _this_.thumbV.style.width  = trackV_Rect.width  + "px";
          var thumbVHeight = trackV_Rect.height * hgtRatio;
          if(thumbVHeight < thumbMinLength) thumbVHeight = thumbMinLength;
          _this_.thumbV.style.height = thumbVHeight + "px";
          _this_.thumbV.style.top = "0px";
          _this_.thumbV.style.borderRadius = thumbThickness / 2 + "px";
          
          _this_.thumbTopEdge = _this_.thumbV.getBoundingClientRect().y; 
          _this_.movableLengV = _this_.scrollRect.height - _this_.frameRect.height;
          _this_.movableBarV  = (_this_.frameRect.height * trackLengRatio) - thumbVHeight;
        }
      }
    
      createHTML(){
        // Horizontal
        const horizontalTrack = document.createElement("div");
        horizontalTrack.classList.add("scroll-track-h");
        const horizontalThumb = document.createElement("div");
        horizontalThumb.classList.add("scroll-thumb-h");
        horizontalTrack.appendChild(horizontalThumb);
        this.frameArea.appendChild(horizontalTrack);
        
        // vertical
        const verticalTrack = document.createElement("div");
        verticalTrack.classList.add("scroll-track-v");
        const verticalThumb = document.createElement("div");
        verticalThumb.classList.add("scroll-thumb-v");
        verticalTrack.appendChild(verticalThumb);
        this.frameArea.appendChild(verticalTrack);
      };
    
      // event 
      // mouse down on horizontal thumb 
      holdThumbH(e, _this_){
        console.log("mouseDown");
        _this_.activeH = true;
        _this_.thumbHLeftEdge = e.offsetX;
      } 
    
      // mouse down on vertical thumb 
      holdThumbV(e, _this_){
        console.log("mouseDown");
        _this_.activeV = true;
        _this_.thumbVTopEdge  = e.offsetY;
      }
    
      // mouse move on document
      // because dont move only on thumb 
      moveThumb(e, _this_){ 
        if(!_this_.activeH && !_this_.activeV){ 
          return; }
        e.preventDefault();
        
        if(_this_.activeH){
          e = e.clientX;
          console.log("horizontal: " + e);
          // it use css style "translate".
          // because need diff length from element initial pos.
          var curThumbLeftX = e - _this_.trackH.getBoundingClientRect().x - _this_.thumbHLeftEdge;
          var trackRatio = curThumbLeftX / _this_.movableBarH;
          var actualMoveX = _this_.movableLengH * trackRatio;
          if(curThumbLeftX < 0) 
            curThumbLeftX = 0;
          else if(curThumbLeftX > _this_.movableBarH) 
            curThumbLeftX = _this_.movableBarH;
          _this_.thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";
          _this_.frameArea.scrollLeft = actualMoveX;
        }
        if(_this_.activeV){
          e = e.clientY;
          console.log("vertical: " + e);
          var curThumbTopY = e - _this_.trackV.getBoundingClientRect().y - _this_.thumbVTopEdge;
          var trackRatio = curThumbTopY / _this_.movableBarV;
          var actualMoveY = _this_.movableLengV * trackRatio;
          if(curThumbTopY < 0) 
            curThumbTopY = 0;
          else if(curThumbTopY > _this_.movableBarV) 
            curThumbTopY = _this_.movableBarV;
          _this_.thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
          _this_.frameArea.scrollTop = actualMoveY;
        }
      }
    
      releaseThumb(e, _this_){
        console.log("mouseUp");
    
        // wait for move sequence.
        requestAnimationFrame(function() {
          document.body.style.overflow = "";
          e.stopPropagation();
          _this_.activeV = false;
          _this_.activeH = false;
        }, 0);
      }
    
      scrollingArea(_this_){
        console.log("scrolled");
        if(_this_.activeH || _this_.activeH) {
          return;  
        }
        var scrolltop  = _this_.frameArea.scrollTop;
        var scrollleft = _this_.frameArea.scrollLeft;
        var scrollRatioH = scrollleft / _this_.movableLengH;
        var scrollRatioV = scrolltop  / _this_.movableLengV;
        var curThumbTopY = _this_.movableBarV * scrollRatioV;
        var curThumbLeftX= _this_.movableBarH * scrollRatioH;
        
        _this_.thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
        _this_.thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";
        
      }
    
      // click in trackbar
      clickTrackH(e, _this_){
        if(_this_.activeH) {return;} // reject mousemove and release
        
        console.log("clicked Horizontal");
        e.preventDefault();
        
        var thumbNewX = e.offsetX - _this_.thumbH.scrollWidth / 2;
        if(thumbNewX < 0)
          thumbNewX = 0;
        else if(thumbNewX > _this_.movableBarH)
          thumbNewX = _this_.movableBarH;
        _this_.thumbH.style.transform = "translateX(" + thumbNewX + "px)";
        
        var trackRatio = thumbNewX / _this_.movableBarH;
        var actualMoveX = _this_.movableLengH * trackRatio;
        _this_.frameArea.scrollLeft = actualMoveX; 
      }
    
      clickTrackV(e, _this_){
        if(_this_.activeV) { return ;}
        console.log("clicked vertical");
        e.preventDefault();
        
        var thumbNewY = e.offsetY - _this_.thumbV.scrollHeight / 2;
          if(thumbNewY < 0)
          thumbNewY = 0;
        else if(thumbNewY > _this_.movableBarV)
          thumbNewY = _this_.movableBarV;
        _this_.thumbV.style.transform = "translateY(" + thumbNewY + "px)";
        
        var trackRatio = thumbNewY / _this_.movableBarV;
        var actualMoveY = _this_.movableLengV * trackRatio;
        _this_.frameArea.scrollTop = actualMoveY;
      }
    
    }
    
    // called after Dom loaded
    document.addEventListener("DOMContentLoaded", (e) => {
      var frames = document.querySelectorAll(".frame-area");
      for( i = 0; i < frames.length; i++){
        var scroller = new customScroller(frames[i]);
      }
    });
    

    codepenはこちらから。
    気に入っていただけたらLove、Pinお願いします。

    See the Pen custom scrollbar class by keiyashi (@keiyashi) on CodePen.

  • 独自スクロールバーをjavascript+scssだけで実装してみる

    独自スクロールバーをjavascript+scssだけで実装してみる

    せっかく細部までこだわってサイトを作ったのにスクロールバーが普通過ぎる…
    でも細かい挙動がわからないプラグインは入れたくない…

    そんなあなたにJavascriptとcss(scss)だけで作れる独自スクロールバーを紹介します。

    まずはCodepenから

    See the Pen custom scroll bar 2 by keiyashi (@keiyashi) on CodePen.


    まずは要求を明らかにしましょう。

    <要求>
    ・通常スクロールバーの挙動を満たす
     → 縦横のスクロールバーがでる
       スクロールサム(スクロール操作するためのボタンみたいなやつ)と同期した画面のスクロール
       スクロールトラック(スクロールサムの通り道)をクリックすると、サムが追従し画面もスクロールする
       画面をボタンやホイールでスクロールするとサムが追従する
       スクロールが必要のない大きさになったらスクロールバーが隠れる
    ・スクロールサムやトラックを自由に変更できる
    ・スクロールバーのHTMLはJavascriptで自動生成する

    ”スクロールが必要な要素にクラスを追加するだけでjsがいい感じにしてくれるようにしたい!”、というのが導入しやすい一番の肝かと思います。
    なのでJSでスクロールバーを自動生成するようにしました。

    まずはHTMLから
    <div class="frame-area">
      <div class="scroll-area">
     ~~~~~本文~~~~~~~~~
     </div>
    </div>

    中身となるScroll-areaと、枠を決めるFrame-areaだけのシンプルな構成です。

    次はcss(scss)
    .frame-area{
      position:relative;
      top:20dvh;
      left:20dvw;
      width:50dvw;
      height:50dvh;
      resize: both;
      overflow:auto;
      scrollbar-width: none;
      background-color: lightblue;
    
      .scroll-area{
        width: fit-content;
        white-space:pre;
      }
      
      .scroll-track-h{
        cursor: pointer;
        position:fixed;  
        
        &::after{
          content:"";
          display: block;
          position:absolute;
          width:100%;
          height:1px;
          border-radius:0.5px;
          background-color: black;
          bottom: 50%;
        }
        
        .scroll-thumb-h{
          position:relative;
          background-color: black;
          transition: all .2s;
        }
      }
      
      .scroll-track-v{
        cursor: pointer;
        position:fixed;  
        
        &::after{
          content:"";
          display: block;
          position:absolute;
          width:1px;
          height:100%;
          border-radius:0.5px;
          background-color: black;
          bottom: 0%;
          left: 4px;
          
        }
        
        .scroll-thumb-v{
          position:relative;
          background-color: black;
          transition: all .2s;
        }
      } 
    }

    frame-areaで重要なのは次のスタイルです。
     over-flow:auto → サイズ以上に内容がはみ出る場合はスクロール可能にする設定(scrollでも可)
     scrollbar-width: none → デフォルトのスクロールバーを非表示にする設定

    scroll-areaは特に重要なスタイルはありません。


    次にスクロールバーのスタイルです。
    ”子要素にscroll-thumbを持つscroll-track”というセットを、Horizontal用とVertical用で分けて生成します。
    trackにPosition:fixedを指定するのは、スクロールしてもtrackが同じ位置に残ってほしいためです。

    // element
    var frameArea ;
    var scrollArea ;
    var trackH ;
    var thumbH ;
    var trackV ;
    var thumbV ;
    
    // variable
    var frameRect;        // current frame rect info.
    var scrollRect;       // current scroll rect info.
    var thumbVTopEdge;    // client Y about vertical thumb when mouse down.  
    var thumbHLeftEdge;   // client X about horizontal thumb when mouse down.
    var movableLengH;     // length that scroll-area can move in horizontal.  
    var movableLengV;     // length that scroll-area can move in vertical.
    var movableBarH;      // length that horizontal thumb on horizontal track.
    var movableBarV;      // length that vertical thumb on vertical track.
    
    // props
    const thumbThickness = 10;
    const trackLengRatio = 0.8;
    const thumbMinLength = 20;
    const trackDistanceFromEdge = 5;
    
    // flags
    var activeH;          // is horizontal thumb draggin?
    var activeV;          // is vertical thumb draggin?
    
    
    // called after Dom loaded
    document.addEventListener("DOMContentLoaded", (e) => {
      createHTML();
      initialize();
      updateTrack();
    });
    
    function createHTML(){
      scrollArea  = document.querySelector('.scroll-area');
      frameArea   = document.querySelector('.frame-area');
      
      // Horizontal
      const horizontalTrack = document.createElement("div");
      horizontalTrack.classList.add("scroll-track-h");
      const horizontalThumb = document.createElement("div");
      horizontalThumb.classList.add("scroll-thumb-h");
      horizontalTrack.appendChild(horizontalThumb);
      frameArea.appendChild(horizontalTrack);
      
      // vertical
      const verticalTrack = document.createElement("div");
      verticalTrack.classList.add("scroll-track-v");
      const verticalThumb = document.createElement("div");
      verticalThumb.classList.add("scroll-thumb-v");
      verticalTrack.appendChild(verticalThumb);
      frameArea.appendChild(verticalTrack);
    };
    
    // initializer
    function initialize(){
      trackH      = document.querySelector('.scroll-track-h');
      thumbH      = document.querySelector('.scroll-thumb-h');
      trackV      = document.querySelector('.scroll-track-v');
      thumbV      = document.querySelector('.scroll-thumb-v');
    
      thumbH.addEventListener("mousedown",   holdThumbH, { passive: true });
      thumbV.addEventListener("mousedown",   holdThumbV, { passive: true });
      document.addEventListener("mousemove", moveThumb, { passive: false }); 
      // "passive: false" means callback has preventDefault function
      document.addEventListener("mouseup",   releaseThumb);
      scrollArea.addEventListener("selectstart",   function(e) {
        if(activeH || activeV) 
          e.preventDefault();
      });
      
      frameArea.addEventListener("scroll",  scrollingArea);
      trackH.addEventListener("click",       clickTrackH, { passive: false });
      trackV.addEventListener("click",       clickTrackV, { passive: false });
      
      window.addEventListener('resize', updateTrack);
      frameResizingObserver();
      
      thumbHLeftEdge = 0;
      thumbVTopEdge  = 0;
      movableLengH   = 0;
      movableLengV   = 0;
      movableBarH    = 0;
      movableBarV    = 0;
      activeH        = false;
      activeV        = false;
    }
    
    function frameResizingObserver(){
      const observer = new MutationObserver(updateTrack);
      observer.observe(frameArea, {attributes: true, attributeFilter: ["style"]});
    }
    
    // update variable 
    // recalled by window resize or frame resize
    function updateTrack(){
      frameRect  = frameArea.getBoundingClientRect();
      scrollRect = scrollArea.getBoundingClientRect();
      
      if(frameRect.width > scrollRect.width){
        trackH.style.visibility = "hidden";
        thumbH.style.visibility = "hidden";
      }else{
        trackH.style.visibility = "visible";
        thumbH.style.visibility = "visible";
      }
      
      if(frameRect.height > scrollRect.height){
        trackV.style.visibility = "hidden"; 
        thumbV.style.visibility = "hidden"; 
      }else{
        trackV.style.visibility = "visible";
        thumbV.style.visibility = "visible";
      }
        
      // horizontal scrollbar 
      trackH.style.width  = (frameRect.width * trackLengRatio) + "px";
      trackH.style.height = thumbThickness + "px";
      trackH.style.left   = (frameRect.x + frameRect.width * (1 - trackLengRatio) / 2)  + "px";
      trackH.style.top    = (frameRect.y + frameRect.height - (thumbThickness + trackDistanceFromEdge)) + "px";
      
      var widRatio = (frameRect.width * trackLengRatio)  / scrollRect.width;
      var hgtRatio = (frameRect.height * trackLengRatio) / scrollRect.height;
       
      var trackH_Rect = trackH.getBoundingClientRect();
      var thumbHWidth  = trackH_Rect.width  * widRatio;
      if(thumbHWidth < thumbMinLength) thumbHWidth = thumbMinLength;
      thumbH.style.width  = thumbHWidth + "px";
      thumbH.style.height = trackH_Rect.height + "px";
      thumbH.style.top = "0px";
      thumbH.style.borderRadius = thumbThickness / 2 + "px";
      
      thumbLeftEdge = thumbH.getBoundingClientRect().x; 
      movableLengH = scrollRect.width - frameRect.width;
      movableBarH  = (frameRect.width * trackLengRatio) - thumbHWidth;
      
      // vertical scrollbar
      trackV.style.width  = thumbThickness + "px";
      trackV.style.height = (frameRect.height * trackLengRatio) + "px";
      trackV.style.left   = (frameRect.x + frameRect.width - (thumbThickness + trackDistanceFromEdge))  + "px";
      trackV.style.top    = (frameRect.y + frameRect.height * (1 - trackLengRatio) / 2) + "px";
       
      var trackV_Rect = trackV.getBoundingClientRect();
      thumbV.style.width  = trackV_Rect.width  + "px";
      var thumbVHeight = trackV_Rect.height * hgtRatio;
      if(thumbVHeight < thumbMinLength) thumbVHeight = thumbMinLength;
      thumbV.style.height = thumbVHeight + "px";
      thumbV.style.top = "0px";
      thumbV.style.borderRadius = thumbThickness / 2 + "px";
      
      thumbTopEdge = thumbV.getBoundingClientRect().y; 
      movableLengV = scrollRect.height - frameRect.height;
      movableBarV  = (frameRect.height * trackLengRatio) - thumbVHeight;
    }
    
    // event 
    // mouse down on horizontal thumb 
    function holdThumbH(e){
      console.log("mouseDown");
      activeH = true;
      thumbHLeftEdge = e.offsetX;
    }
    
    // mouse down on vertical thumb 
    function holdThumbV(e){
      console.log("mouseDown");
      activeV = true;
      thumbVTopEdge  = e.offsetY;
    }
    
    // mouse move on document
    // because dont move only on thumb 
    function moveThumb(e){ 
      if(!activeH && !activeV){ return; }
      e.preventDefault();
       
      if(activeH){
        e = e.clientX;
        console.log("horizontal: " + e);
        // it use css style "translate".
        // because need diff length from element initial pos.
        var curThumbLeftX = e - trackH.getBoundingClientRect().x - thumbHLeftEdge; // A
        var trackRatio = curThumbLeftX / movableBarH;                              // B
        var actualMoveX = movableLengH * trackRatio;                               // C
        if(curThumbLeftX < 0) 
          curThumbLeftX = 0;
        else if(curThumbLeftX > movableBarH) 
          curThumbLeftX = movableBarH;
        thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";            // D
        frameArea.scrollLeft = actualMoveX;                                        // E
      }
      if(activeV){
        e = e.clientY;
        console.log("vertical: " + e);
        var curThumbTopY = e - trackV.getBoundingClientRect().y - thumbVTopEdge;
        var trackRatio = curThumbTopY / movableBarV;
        var actualMoveY = movableLengV * trackRatio;
        if(curThumbTopY < 0) 
          curThumbTopY = 0;
        else if(curThumbTopY > movableBarV) 
          curThumbTopY = movableBarV;
        thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
        frameArea.scrollTop = actualMoveY;
      }
    }
    
    function releaseThumb(e){
      console.log("mouseUp");
    
      // wait for move sequence.
      requestAnimationFrame(function() {
        document.body.style.overflow = "";
        e.preventDefault();
        activeV = false;
        activeH = false;
      }, 0);
    }
    
    function scrollingArea(e){
      console.log("scrolled");
      if(activeH || activeH) {
        return;  
      }
      var scrolltop  = frameArea.scrollTop;
      var scrollleft = frameArea.scrollLeft;
      var scrollRatioH = scrollleft / movableLengH;
      var scrollRatioV = scrolltop  / movableLengV;
      var curThumbTopY = movableBarV * scrollRatioV;
      var curThumbLeftX= movableBarH * scrollRatioH;
      
      thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
      thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";
    }
    
    // click in trackbar
    function clickTrackH(e){
      if(activeH) {return;} // reject mousemove and release
      
      console.log("clicked Horizontal");
      e.preventDefault();
      
      var thumbNewX = e.offsetX - thumbH.scrollWidth / 2;
      if(thumbNewX < 0)
        thumbNewX = 0;
      else if(thumbNewX > movableBarH)
        thumbNewX = movableBarH;
      thumbH.style.transform = "translateX(" + thumbNewX + "px)";
      
      var trackRatio = thumbNewX / movableBarH;
      var actualMoveX = movableLengH * trackRatio;
      frameArea.scrollLeft = actualMoveX; 
    }
    
    function clickTrackV(e){
      if(activeV) { return ;}
      console.log("clicked vertical");
      e.preventDefault();
    
      var thumbNewY = e.offsetY - thumbV.scrollHeight / 2;
        if(thumbNewY < 0)
        thumbNewY = 0;
      else if(thumbNewY > movableBarV)
        thumbNewY = movableBarV;
      thumbV.style.transform = "translateY(" + thumbNewY + "px)";
      
      var trackRatio = thumbNewY / movableBarV;
      var actualMoveY = movableLengV * trackRatio;
      frameArea.scrollTop = actualMoveY;
    }

    DOMがロードされたあと、HTMLの生成 ⇒ 各種変数の初期化 ⇒ スクロール対象のサイズのアップデート が実行されます。
    初期化の中でイベントリスナーの追加が行われるので、初期化以降はイベント駆動で動いていきます。

    1.HTMLの生成
      TrackとThumbを生成してframe-areaに追加します。
      Classを追加するもの忘れずに。

    2.初期化
      変数を初期化、イベントリスナーの追加します。 

    3.trackとthumbの位置・サイズ調整
      Windowのリサイズイベント、frame-areaのリサイズが起こるとupdateTrack関数が発生するようになっています。
      frame-areaのリサイズはResizeのイベントをリッスンできないので、Observerで監視します。
      
      リサイズが起きたとき、スクロールが必要ない場合はtrackとthumbを隠します。
      位置計算は適用先の状況によって変わるので適時変えてください。

    4.thumbのドラッグによる移動
      ドラッグ動作ですが、ざっくり次のような形で行われています。
      updateTrack 
        Thumbが移動できる長さとスクロール対象が移動できる長さを事前に計算
      holdThumb
       Activeフラグを立てる
       Thumbの端からクリックした位置までの距離を取得する
      moveThumb
        A Thumbの初期位置と現在位置の差分量を計算
        B 差分量がThumb移動可能範囲の何割にあたるかを計算
        C その割合をスクロール可能範囲に乗じてスクロール量を算出
        D Thumbを差分量分だけTranslateする 
        E スクロール量をスクロール対象に適用する
      releaseThumb
        Activeフラグを折る


      考慮しないといけないのは、Thumbのドラッグ中は範囲選択が発動しないようにすること&ほかのマウスイベントを除外することです。
      MouseDown、MouseMove、MouseUpは全部の要素でも発火するため、イベント除外は最重要な考慮事項です。

      ”これから先のマウスイベントはScrollbarに関するイベントである”ことを明示的にするため、scrollThumbがMouseDownした時にactiveフラグを立てるようにしています。
      これでMove/Upのイベント中にもThumbに関するイベントであることを検知することが出来ます。
      Thumbドラッグ中のMouseMove/MosuseUpイベントは、Thumbから離れていても働いてほしいのでDocumentでイベントリッスンしている点に注意してください。
      
      Thumbドラッグ中はほかのイベントを却下したいので、Active中のMove/UpではPreventDefaultしています。
      upイベントでrequestAnimationFrameでActive=falseを囲っているのは、Moveのアニメーションが終了してから処理したいためです。
      スクロールイベントは負荷が高く、Upイベントが無視される場合の対策です。
      
      PreventDefaultを使用している関数をイベントリスナーに登録する場合、addEventListenerの第3引数に{ passive:false }を指定すると
    明示的にPreventDefaultをつかなわないことをブラウザに通知し処理を減らすことが出来ます。
      https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener


    5.Trackをクリックしたときの動作
      クリックした位置にThumbの真ん中が来るようにしましょう。
      trackがクリックされたときのe.offset=Track端からの距離なので、オフセット量からThumbの長さの半分を減算した量でThumbをTranslateします。
      あとは実スクロール量の算出をMoveイベントと同じように行えばOKです。

    6.ホイールやキーボードで移動した場合の動作
      overflow:auto(scroll)が設定された要素にスクロールイベントを登録します。
      ScrollTopやScrollLeftでスクロール位置が取得できるので、それを利用してスクロール可能量のうち何割移動したかを算出しましょう。
      あとはその割合をThumb移動可能量に乗じて、新しいThumb位置を算出しTranslateすればOKです。


    これでカスタムスクロールバーが出来ました。



    <注意点>
    position:fixedが指定された要素は、親以上の要素にTransformがかかっていると無効になります。
    https://www.w3.org/TR/css-transforms-1/#transform-rendering

    この場合はTransform以外の方法で位置調整をするか、親子関係を解消するしかありません。


  • anime.jsで変化し続けるグラデーションを作る2

    anime.jsで変化し続けるグラデーションを作る2

    See the Pen change keycolor in marble gradient with animejs by keiyashi (@keiyashi) on CodePen.

    先の記事では自動でランダムに変化するグラデーションを作ることが出来ました。
    次はインタラクティブに色相を変化させる処理を追加してみましょう。

    方針は次の通りです。
    ・ラジオボタンを作って色を割り振る
    ・ラジオボタンを押すと、割り振った色相角度を中心に一定の範囲内で色を変化させる
    ・ランダムグラデーションに戻るボタンも作る

    まずはHTMLから
    <div class="grad-board">
      <div id="marble-ball-1" class="marble-ball"></div>
      <div id="marble-ball-2" class="marble-ball"></div>
      <div id="marble-ball-3" class="marble-ball"></div>
      <div id="marble-ball-4" class="marble-ball"></div>
      <div id="marble-ball-5" class="marble-ball"></div>
      
      <div class="radios">
        <div class="radio-case">
          <input id="red" name="color-btn" type="radio" class="button">
          <label for="red" class="color-name">red</label>
        </div>
        <div class="radio-case">
          <input id="yellow" name="color-btn" type="radio" class="button">
          <label for="yellow" class="color-name">yellow</label>
        </div>
        <div class="radio-case">
          <input id="green" name="color-btn" type="radio" class="button">
          <label for="green" class="color-name">green</label>
        </div>
        <div class="radio-case">
          <input id="blue" name="color-btn" type="radio" class="button">
          <label for="blue" class="color-name">blue</label>
        </div>
        <div class="radio-case">
          <input id="purple" name="color-btn" type="radio" class="button">
          <label for="purple" class="color-name">purple</label>
        </div>
        <div class="radio-case">
          <input id="gradient" name="color-btn" type="radio" class="button">
          <label for="gradient" class="color-name">gradient</label>
        </div>
      </div>
    </div>

    <div class="radios">以下が新しく追加した部分になります。
    <input>と<label>を<div>でくくったものを1つの色セットとして、必要分準備します。
    今回は赤・黄色・緑・青・紫、そしてランダムカラーの7セットを準備しています。

    inputをラジオボタンとして使うには、属性にtype="radio"・name="任意の名前”を指定すればOKです。
    nameはラジオボタンのグループ名だと思ってください。
    labelのfor属性にセットになってるラジオボタンのidを指定すると、ボタンと連携できます。
    ラベルをクリックしてもラジオボタンの選択状態を変えることが出来るようになります。

    .grad-board{
      width :100dvw;
      height:100dvh;  
      display: block;
      position: relative;
      background-color:white;
      padding:0;
      margin:0;
      overflow:hidden;
      
      @media screen and (orientation:portrait)  { --dot-size : 100dvh;}
      @media screen and (orientation:landscape) { --dot-size : 100dvw;}
      
      .marble-ball{
        position: absolute;
        z-index: 0;
        width: var( --dot-size);
        height: var(--dot-size);
        border-radius: calc(var(--dot-size) / 4);
        transform-origin: center;
        background: radial-gradient(rgb(0,128,128) 0px, transparent 70%);
      }
    
      #marble-ball-1{
        left: calc(50dvw - var(--dot-size)/2);
        top:  calc(50dvh - var(--dot-size)/2);
      }
      #marble-ball-2{
        left: calc(25dvw - var(--dot-size)/2);
        top:  calc(25dvh - var(--dot-size)/2);
      }
      #marble-ball-3{
        left: calc(25dvw - var(--dot-size)/2);
        top:  calc(75dvh - var(--dot-size)/2);
      }
      #marble-ball-4{
        left: calc(75dvw - var(--dot-size)/2);
        top:  calc(25dvh - var(--dot-size)/2);
      }
      #marble-ball-5{
        left: calc(75dvw - var(--dot-size)/2);
        top:  calc(75dvh - var(--dot-size)/2);
      }
      
      .radios{
        z-index:10;
        display:flex;
        gap:1;
        position:absolute;
        flex-direction:column;
        margin-top:2em;
        
        .radio-case{      
          .button{
            visibility:hidden;
          }
          .button:checked+label {
              color: white;
          }
          
          .color-name{
            font-size:2em;
          }
          .color-name:hover {
            color: white;
          }
        }
      }
    }
    

    次はCSS(scss)です。
    こちらは特にいうことないです。
    // inspired by "Layered animations with anime.js"
    // https://codepen.io/juliangarnier/pen/LMrddV
    // and it needs to change anime.js about dealing colors.
    // please check my github. 
    // https://github.com/keiyashi/animejs_for_marbleGradient
    
    var currentHue = 0;
    var angleSetting = 20;
    var angleDefault = 180;
    var hueAngle = 180;
    var colorMap ;
    
    document.addEventListener("DOMContentLoaded", (event) => {
      layeredAnimation();
    
      colorMap ={        // hue angle
        'red':      {hue: 0   , angle: angleSetting } ,  
        'yellow':   {hue: 60  , angle: angleSetting } ,
        'green':    {hue: 120 , angle: angleSetting } , 
        'blue':     {hue: 200 , angle: angleSetting } ,
        'purple':   {hue: 300 , angle: angleSetting } ,
        'gradient': {hue: 180 , angle: angleDefault }
      };
      
      radioSwitchInit();
    });
    
    function layeredAnimation(){
        var layeredAnimationEl = document.querySelector('.grad-board');
        var colorPints = layeredAnimationEl.querySelectorAll('.marble-ball');
    
        for (var i = 0; i < colorPints.length; i++) {
            animateShape(colorPints[i]);
        }
    }
    
    function animateShape(el) {
        var easings = ['easeInOutQuad', 'easeInOutCirc', 'easeInOutSine'];
        var animation = anime.timeline({
            targets: el,
            duration: function() { return anime.random(3000, 4000); },
            easing: function() { return easings[anime.random(0, easings.length - 1)]; },
            complete: function(anim) { animateShape(anim.animatables[0].target); },
        })
        .add({
            translateX: anime.random(-500, 500),
            translateY: anime.random(-400, 400),
            scale: anime.random(100, 200) / 100,
            background: makeColorContext(),
        }, 0);
    }
    
    function makeColorContext(){
        var angle = hueAngle;
        var angleBand = anime.random(-angle, angle);
        var hue   = currentHue + angleBand;
        if(hue >= 360){
            hue = 720 - hue;
        }else if(hue < 0){
            hue = 360 + hue;
        }
      
       var colorText = 'hsl(' + Math.round(hue) + ', ' + 80 + '%, ' + 50 + '%)';
        var outputContext = 'radial-gradient(' + anime.hsl2Rgb(colorText) + ' 0px, transparent 50%)';
        return outputContext;
    }
    
    function radioSwitchInit(){
        var radios = document.querySelectorAll('.button');
        radios.forEach(curRadio => {
            curRadio.addEventListener("change", (e) =>{
                currentHue = colorMap[curRadio.id].hue;
                hueAngle   = colorMap[curRadio.id].angle;
            })
         })
    }

    今回、ラジオボタンのIDをキーにしたカラーマップを準備しています。
    バリューには色相角度と、取り得る角度の範囲を持たせています。

    変数として選択中の色相角度と変化可能角度があり、任意のラジオボタンが押下されるとカラーマップから該当するidの色相角度と範囲を取得します。

    上記変数はアニメーション中に参照しているので、ラジオボタンを押すことでグラデーションカラーが変わります。

    無事、インタラクティブな色の変化を起こすことが出来ました。
  • anime.jsで変化し続けるグラデーションを作る

    anime.jsで変化し続けるグラデーションを作る
    関連項目…
     HTML、SCSS、javascript、anime.js

    web上でいろんなエフェクトをつけたい…
    でもプラグインを利用すると干渉しあったり変な挙動になったりする…
    ReactやVueで作ってもWordpressのようなCMSに適用するのはなんか複雑…

    そんな貴方に使っていただきたい知識たちを紹介します。
    当サイトでも使われている、グラデーションで変化し続けるエフェクトを、anime.jsで作っていきます。
    使うライブラリはanime.jsのみです。

      ~~~~~~~~~~~ anime.js とは? ~~~~~~~~~~~~~~

      シンプルでありながら強力なAPIを備えた軽量Javascriptアニメーションライブラリ(本文まま)
       https://animejs.com/
      何がいいって、NpmとかBuildとかカスタムフィールド作って無理やり追加するとかなしに、シンプルなJavaScriptLibraryであること。
      CMS使ってウェブサイト作っている最中に、エフェクト追加したくなっちゃった、そんな時に簡単に導入できる。
      クラス追加を強制しないし、既存コードにも追加しやすいところも素敵。
      歴史も古いので参考資料がたくさんあるものよいところ。

      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    CodePenをお見せしますので、こちらを見ながら解説していきます。
    見づらい場合はCodePenのサイトに移ってみてください。
     https://codepen.io/keiyashi/pen/RwXjamN
    気に入ったらLoveボタン、Pin留めよろしくお願いします。

    See the Pen transition marble gradient with animejs by keiyashi (@keiyashi) on CodePen.


    まず、やりたいこと明らかにしていきましょう。


    <要求>

    ・色が時間変化
     → リニアな変化でなく、2次元的にランダムな変化をさせたい。
    ・色調は固定
     → 色調(トーン:tone)は色の調子であり、PCCS(日本色研配色体系)ではビビッド・ソフト・ペールなど12の色調群に分けられている。
       色調も変化してしまうと統一感が失われるので、彩度と明度を維持して色相だけ変化させたい。
    ・滑らかに変化する
     → 色の変化が途切れない。


    <情報収集>

    ・cssと簡単なjsでどうにかならないか?
     → Linear-gradient及びRadial-gradientの指定はできるが、うねうね動き続けるのは難しい。
       mesherで生成するような複数のグラデーションを混ぜて、そのパラメータを時間変化させようとしても切り替わりは滑らかにならない。
    ・簡単に導入できるライブラリで解決できないか?
     → ReactやVue、Three.jsなどは多彩なことが出来るが導入コストが高い。
       anime.jsはjavascriptライブラリで、Wordpressに追加しやすく応用が利く。サンプルが多い。
       またanime.jsは色の変化もサポートしており、色から色への変化を滑らかにつないでくれる。
       これは使えそうな気がします。
    ・cssの色空間は?
     → 加法色空間(色を加えていくと白くなる)なので、色を重ねても汚くならない。
       透過をもつhtml要素を重ねることでグラデーションが表現できる。


    <方向性>

    以上のことから以下の方向での実装を探ります。

    ・大きい丸(ウィンドウをカバーするくらい大きい丸)を複数準備する
    ・それぞれの丸にはキーとなる色とTransparent(透明)の放射グラデーションを指定する
    ・透過グラデーションのある大きい丸を重ねることで、複雑なグラデーションを表現
    ・丸の位置や大きさを変化させてさらに複雑なグラデーションに!


    <実装>

     html…
    <div class="grad-board">
      <div id="marble-ball-1" class="marble-ball"></div>
      <div id="marble-ball-2" class="marble-ball"></div>
      <div id="marble-ball-3" class="marble-ball"></div>
      <div id="marble-ball-4" class="marble-ball"></div>
      <div id="marble-ball-5" class="marble-ball"></div>
    </div>
    シンプルですね。
    背景になるgrad-boardと、動く丸1~5があります。

    ではcss(scss)をつけていきましょう。
    .grad-board{
      width :100dvw;
      height:100dvh;  
      display: block;
      position: relative;
      background-color:white;
      padding:0;
      margin:0;
      overflow:hidden;
      
      @media screen and (orientation:portrait)  { --dot-size : 100dvh;}
      @media screen and (orientation:landscape) { --dot-size : 100dvw;}
      
      .marble-ball{
        position: absolute;
        z-index: 1;
        width: var( --dot-size);
        height: var(--dot-size);
        border-radius: calc(var(--dot-size) / 4);
        transform-origin: center;
        background: radial-gradient(rgb(0,128,128) 0px, transparent 70%);
      }
    
      #marble-ball-1{
        left: calc(50dvw - var(--dot-size)/2);
        top:  calc(50dvh - var(--dot-size)/2);
      }
      #marble-ball-2{
        left: calc(25dvw - var(--dot-size)/2);
        top:  calc(25dvh - var(--dot-size)/2);
      }
      #marble-ball-3{
        left: calc(25dvw - var(--dot-size)/2);
        top:  calc(75dvh - var(--dot-size)/2);
      }
      #marble-ball-4{
        left: calc(75dvw - var(--dot-size)/2);
        top:  calc(25dvh - var(--dot-size)/2);
      }
      #marble-ball-5{
        left: calc(75dvw - var(--dot-size)/2);
        top:  calc(75dvh - var(--dot-size)/2);
      }
    }
    
    
    
    grad-boardに、サイズやはみ出た部分の非表示を設定していきます。

    丸が重ならないとうまくグラデーションが表現できないので、丸のサイズは描画領域の縦横の大きいほうと同じ直径にしています。
    丸のBackgroundColorはJavascriptで変化させるので、初期値は入れなくてもいいかもしれませんが、間違いがあった時に差異がわかりやすいように入れておきます。

    また、animationではTranslateXとTranslateYで丸の位置を変化させるため、基準となる位置を指定しておきます。
    それぞれの丸にPosition:absoluteを指定し、TopとLeftで初期位置を渡しておきます。
    // inspired by "Layered animations with anime.js"
    // https://codepen.io/juliangarnier/pen/LMrddV
    
    document.addEventListener("DOMContentLoaded", (event) => {
        layeredAnimation();
    });
    
    function layeredAnimation(){
        var layeredAnimationEl = document.querySelector('.grad-board');
        var colorPints = layeredAnimationEl.querySelectorAll('.marble-ball');
    
        for (var i = 0; i < colorPints.length; i++) {
            animateShape(colorPints[i]);
        }
    }
    
    function animateShape(el) {
        var easings = ['easeInOutQuad', 'easeInOutCirc', 'easeInOutSine'];
        var animation = anime.timeline({
            targets: el,
            duration: function() { return anime.random(3000, 4000); },
            easing: function() { return easings[anime.random(0, easings.length - 1)]; },
            complete: function(anim) { animateShape(anim.animatables[0].target); },
        })
        .add({
            translateX: anime.random(-500, 500),
            translateY: anime.random(-400, 400),
            scale: anime.random(100, 200) / 100,
            background: makeColorContext(),
        }, 0);
    }
    
    このコードは、anime.jsのサンプル ”Layered animations with anime.js"にインスパイアされて作っています。
    丸の動きはサンプルのモノを簡略化して使っています。

    このスクリプトの核はanimateShape関数です。
    anime.timeline({条件}).add({遷移先})でアニメーションが定義されており、completeで再帰的に同じ関数が呼び出されます。
    条件に記載されているのは、
    ・Targets : アニメーションさせたい対象
    ・Duration : アニメーションが行われる時間
    ・Easing : 変化の仕方
    ・Complete: アニメーション終了後に呼び出される処理
    となっています。
    {条件}にはLoopやらDelayやらDirectionやら指定ができますが、詳しく知りたい方は本家ドキュメントを参照ください。
    時間にかかわる要素はすべてミリ秒での指定です。

    {遷移先}では、Targetsで指定された要素がどのように変化してほしいか、が記述されています。
    今回はTranslateX、TranslateY、Scale、Backgroundが変化対象として指定されています。

    サンプルに従えばBackgroundに色を指定することでアニメートできるようです。
    早速、グラデーションを当てて確認してみましょう。
    radial-gradient(at 50% 50%, hsl(60, 80%,60%) 0%, transparent 70%)


    …色が変わりません。
    グラデーションでないhslでは適用しました。
    ちょっとanime.jsの中身をのぞいてみます。

    …Backgroundで検索をかけてもヒットしません。
    ではcolorはどうでしょうか?
    …色であるかをチェックする関数、色であればrgbaに変換する関数がありました。
    どうやらここを通すことでanime.jsが扱える形に変えているようです。
    is.col関数にradial-gradientを色として認識できるように変更します。
    変更を加えたanime.jsはこちらに置いておきます。

     https://github.com/keiyashi/animejs_for_marbleGradient

    これで行けそうな気がします。
    再チャレンジです。
    いい感じにグラデーションがかかってますね。

    RGBで色を指定すると色調の維持が難しいので、HSLで色を指定しました。
    HSLはHue(色相)、Saturation(彩度)、Lightness(明度)の3要素で構成されており、彩度と明度を維持することで色調を維持することが出来ます。
    hue : 0~360
    saturation/lightness: 0~100%

    cssでグラデーションを指定する方法はいくつかありますが、今回は放射グラデーションを使っています。
    記述形式はmesherで出力される形式を使ってみます。

    ちょっとRadial-gradientの解説しましょう。
    放射グラデーションの指定方法はいくつもありますが、基本は同じです。
    要素の縦横何%の位置からグラデーションを開始するか、色の指定と基準点からの距離の情報で指定できます。
    上の例で行くと、html要素の縦50%・横50%を基準にして、基準点から0%の位置からhslで指定した色が始まり、基準点から70%の位置に向かってTransparentになっていく、という感じです。
    3色でも指定できますし、基準点の指定がなくても行けます。
    先ほどのグラデーションは次のグラデーションと等価です。
    radial-gradient(hsl(60, 80% 60%) 0%, transparent 70%)

    じゃあアニメーションに適用してみましょう。
    分かりやすくするため、丸は1つだけにしています。
    animeのBackgroundにmakeColorContext関数を通して上のグラデーションを渡してみます。
    
    function makeColorContext(){
      return 'radial-gradient(at 50% 50%, hsl(60, 80%,60%) 0%, transparent 70%)';
    }
    
    
    ぉ、ぉおん…?
    なんか変ですね…。
    at 50% 50% を at 0% 50%に変えてみましょうか。
    もっと不可解になってしまいました。
    ただ、動きに関していうと終着点は指定通りの位置に来ているように見えます。
    もしかするとat X% Y%もアニメーション対象として認識されてしまっているのかもしれません。

    一旦at~~の部分を取り外してみます。
    位置は望ましいですが色が勝手に変わってしまいます。
    確かに時間変化するグラデーションを作りたいですが、勝手に変化してしまうのはいただけません。

    hslでの指定がよくないのでしょうか?
    rgbでグラデーションを作ってみます。

    いい感じですね。
    ただし、色調はコントロールしたいので、hslで色を指定してからrgb変換してradial-gradientに渡すようにしましょう。
    hslからrgbに変換する関数はanime.jsに記述されているのでanime.js内に下記を追加して呼び出せるようにしておきます。
    anime.hsl2Rgb = hslToRgb;

    さて、放射グラデーションを時間変化させてみましょう
    Backgroundcolorに、ランダムに色を与えるmakeColorContextを実装します。
    
    function makeColorContext(){
        var angleBand = anime.random(-180, 180);
        var hue   = 180 + angleBand;
        var colorText = 'hsl(' + Math.round(hue) + ', ' + 80 + '%, ' + 50 + '%)';
        var outputContext = 'radial-gradient(' + anime.hsl2Rgb(colorText) + ' 0px, transparent 50%)';
        return outputContext;
    }
    色相は0~360度の色相環で表現されています。
    それを踏まえてランダムなRadial-gradientを作りましょう。

    180を基準に、-180~180の範囲のランダムな値を合成して、ランダムな色相を作ります。
    彩度と明度を固定したhsl形式に色相を指定します。
    hslをrgbに変換してradial-gradientを作ります。
    以上。

    では丸1個の状態で試してみましょう。
    とてもいい感じです。

    丸5つで動かしながらやってみましょう。
    Gifでみると重なっている部分のシャギーが気になりますが、実物はもっとなめらかでいい感じです。
    これでランダムに時間変化する2次元グラデーションが出来ました。
    きっともっといい作り方があると思います。
    いい方法があれば教えてください。

    <注意点>
    大きい丸がabsolute指定されているので要素の上下関係で上のほうに来てしまうため、下の要素にタッチできなくなる場合があります。
    その場合は丸にpointer-events:noneを持たせたりして回避して下さい。