【jsステップアップ】[5]拡大(ズーム)機能を作成する

08/04/2019

モーダル要素に拡大機能を追加する

現場で役立つjs実装サンプル。として初心者の方に向けての連載5回目です。
前回作成したモーダル要素に拡大機能を追加します。
難易度としては本連載中で一番難しめかもしれませんが
理解して作成できると他でも考え方や実装手段等色々な面で
応用できるような内容になっているのではないのかなーと思います。

【拡大機能付きスライダー】javascript実装サンプルの連載一覧

  1. [1]初心者の方向けに作成手順や説明等していきます
  2. [2]ドラッグ値/スワイプ値を取得する
  3. [3]スライダーを作成する
  4. [4]モーダルを作成する
  5. [5]拡大(ズーム)機能を作成する
  6. [6]拡大(ズーム)機能を作成する②

モーダルに拡大(ズーム)機能を追加したサンプル

サンプルに関しては前回のモーダル表示要素に拡大機能を追加しています。
ので完成目標という事で第一回目のサンプルと同じ物です。

ズーム機能に関して

ズーム機能

PCの場合マウススクロールで拡大/縮小 + ドラッグで移動
SPの場合ピンチイン/ピンチアウトで拡大/縮小 + スワイプで移動

多分スマホの場合は写真アプリ等で見られる一般的なビューワー。
というと想像しやすいと思います。
文面にしてしまえばこれだけなのですが、
ブラウザ上で再現しようとすると以外と色々な処理が必要になります。

レイアウト(html/css)

要素とスタイルに関しては(html/css)に関しては前回までで完了しているので、
今回以降はすべてjavascriptの説明になります。

ズーム機能を追加するjavascript

ズームクラス抜粋のjavascript

// /* ===========================================================
// # Zoom
// =========================================================== */
class Zoom {
    constructor() {
        if (Zoom.instance) return;
        Zoom.instance = this;
        // -----------------------------
        this.zoom = document.querySelector(".zoom");
        this.zoom__elm = document.querySelector(".zoom__elm");
        this.zoom__info = document.querySelector(".zoom__info");
        this.zoom__area = document.querySelector(".zoom__area");
        this.zoom__origin01 = document.querySelector(".zoom__origin--01");
        this.zoom__origin02 = document.querySelector(".zoom__origin--02");
        // -----------------------------
        this.isMouseEnter = false; // エリア内フォーカスの判定用

        this.baseSize = 768; // 拡大用画像のサイズとりあえず1:1のサイズのみ
        this.areaSize = this.baseSize / 5; // 拡大エリア表示のサイズ

        this.currentScale = 1.0; // スケール初期値・現在値
        this.scaleMin = 1.0; // スケール最小値
        this.scaleMax = 3.0; // スケール最大値
        this.scaleAdd = 0.02; // マウススクロールでのスケール変化値

        this.mousewheelevent = "onwheel" in document ? "wheel" : "onmousewheel" in document ? "mousewheel" : "DOMMouseScroll"; //マウスホイールのイベント
        this.originTimer = new Timer(100); // マウスホイールのタイマー

        this.areaScale = 1.0; //表示エリアのスケール
        this.factorNum = 0.10; // イージング 係数
        this.fractionNum = 0.001; // 繰り上げ・切り下げ 端数
        // -----------------------------
        this.pointerPoint = new Point(); // マウス位置 スケール基準点を決める用
        this.downPoint = new Point(); // ダウン時の要素位置
        this.currentPoint = new Point(); // 現在位置
        this.targetPoint = new Point(); // 目標位置
        this.originPoint = new Point(0.5, 0.5); // 拡大縮小の基準点
        // -----------------------------
        this.resize();
        this.events();
        this.update();
        // -----------------------------
    }

    normalizePoint(_x, _y) {
        // elmRectとbaseSizeに合わせてマウス位置を0.0 ~ 1.0に正規化
        return new Point(
            this.fraction((_x - this.elmRect.left) / this.baseSize),
            this.fraction((_y - this.elmRect.top) / this.baseSize)
        );
    }

    normalizeScale() {
        // スケール 値最小/最大値以内に
        if (this.currentScale < this.scaleMin) this.currentScale = this.scaleMin;
        if (this.currentScale > this.scaleMax) this.currentScale = this.scaleMax;
    }

    fraction(_v) {
        // 位置端数値 繰り上げ繰り下げ
        if (_v < 0.0 + this.fractionNum) _v = 0.0;
        if (_v > 1.0 - this.fractionNum) _v = 1.0;
        return _v;
    }

    resetScale() {
        this.currentScale = 1.0;
        this.areaScale = 1.0 / this.currentScale;
    }

    events() {
        window.addEventListener("resize", this.resize.bind(this), false);

        // PCイベント
        this.zoom.addEventListener("mouseenter", () => { this.isMouseEnter = true; }, false);
        this.zoom.addEventListener("mouseleave", () => { this.isMouseEnter = false; }, false);

        document.addEventListener("mousedown", this.mousedown.bind(this), false);
        document.addEventListener("mousemove", this.mousemove.bind(this), false);
        window.addEventListener(this.mousewheelevent, this.mousewheel.bind(this), { passive: false });

        // スマホイベント
        document.addEventListener("touchstart", this.touchstart.bind(this), false);
        document.addEventListener("touchmove", this.touchmove.bind(this), { passive: false });
        document.addEventListener("gesturechange", this.gesturechange.bind(this), false);
    }

    resize() {
        this.elmRect = this.zoom__elm.getBoundingClientRect();
    }

    mousedown() {
        this.downPoint = new Point(this.currentPoint.x, this.currentPoint.y);
    }

    mousemove(e) {
        if (!this.isMouseEnter) return;

        this.pointerPoint = this.normalizePoint(e.clientX, e.clientY);

        // マウスドラッグ時の処理
        if (EventTouch.instance.isDown)
            this.addDiffPoint();
    }

    mousewheel(e) {
        // PCはマウススクロールでスケール変更
        if (!this.isMouseEnter) return;

        if (!e) e = window.event;
        e.preventDefault();

        if (EventTouch.instance.mouseDelta(e) < 0) {
            this.currentScale -= this.scaleAdd;
        } else {
            this.currentScale += this.scaleAdd;
        }

        this.normalizeScale();
        this.areaScale = 1.0 / this.currentScale;

        if (this.originTimer.isTimeOut) this.updateOrigin();

        this.originTimer.reset();
    }

    touchstart(e) {
        e = e.originalEvent ? e.originalEvent : e;

        if (e.touches.length > 1) {
            const interpolate = Point.interpolate(EventTouch.instance.touchesStartPoints[0], EventTouch.instance.touchesStartPoints[1], 0.5);
            this.pointerPoint = this.normalizePoint(interpolate.x, interpolate.y);
            if (this.originTimer.isTimeOut) {
                this.updateOrigin();
            }
            this.originTimer.reset();
        } else {
            this.downPoint = new Point(this.currentPoint.x, this.currentPoint.y);
        }
    }

    touchmove(e) {
        e.preventDefault();

        if (e.touches.length > 1) return;

        this.addDiffPoint();
    }
    gesturechange() {
        this.currentScale += (EventTouch.instance.scale - 1.0) * 0.1;
        this.normalizeScale();
        this.areaScale = 1.0 / this.currentScale;
    }

    addDiffPoint() {
        const addPoint = new Point(EventTouch.instance.diffPoint.x / this.baseSize, EventTouch.instance.diffPoint.y / this.baseSize);
        this.targetPoint = new Point(this.downPoint.x - addPoint.x, this.downPoint.y - addPoint.y);
    }

    updateOrigin() {
        // 拡大縮小の基準点設定
        const tempOriginPoint = new Point(this.originPoint.x, this.originPoint.y);

        const areaLeft = this.originPoint.x * (this.currentScale - 1.0) / this.currentScale + this.currentPoint.x * this.areaScale;
        const areaTop = this.originPoint.y * (this.currentScale - 1.0) / this.currentScale + this.currentPoint.y * this.areaScale;

        const originX = areaLeft + this.pointerPoint.x * this.areaScale;
        const originY = areaTop + this.pointerPoint.y * this.areaScale;
        this.originPoint = new Point(originX, originY);

        const diffPoint = new Point(this.originPoint.x - tempOriginPoint.x, this.originPoint.y - tempOriginPoint.y);

        diffPoint.x = diffPoint.x - (diffPoint.x * this.currentScale);
        diffPoint.y = diffPoint.y - (diffPoint.y * this.currentScale);

        this.currentPoint.x = this.targetPoint.x = (this.currentPoint.x + diffPoint.x);
        this.currentPoint.y = this.targetPoint.y = (this.currentPoint.y + diffPoint.y);
    }

    update() {
        window.requestAnimationFrame(this.update.bind(this));
        this.moveArea();
        this.easing();
        this.addStyle();
        this.updateInfo();
    }

    moveArea() {
        // originPointに合わせてドラッグ/スワイプでの移動範囲を決める
        this.areaLeftMin = this.originPoint.x - (this.originPoint.x * this.currentScale);
        this.areaRightMax = -((1.0 - this.originPoint.x) - (1.0 - this.originPoint.x) * this.currentScale);
        this.areaTopMin = this.originPoint.y - (this.originPoint.y * this.currentScale);
        this.areaBottomMax = -((1.0 - this.originPoint.y) - (1.0 - this.originPoint.y) * this.currentScale);
    }

    easing() {
        // 位置のイージング処理
        if (this.targetPoint.x <= this.areaLeftMin)
            this.targetPoint.x += (this.areaLeftMin - this.targetPoint.x) * this.factorNum;
        if (this.targetPoint.x >= this.areaRightMax)
            this.targetPoint.x += (this.areaRightMax - this.targetPoint.x) * this.factorNum;
        if (this.targetPoint.y <= this.areaTopMin)
            this.targetPoint.y += (this.areaTopMin - this.targetPoint.y) * this.factorNum;
        if (this.targetPoint.y >= this.areaBottomMax)
            this.targetPoint.y += (this.areaBottomMax - this.targetPoint.y) * this.factorNum;

        this.currentPoint.x += (this.targetPoint.x - this.currentPoint.x) * this.factorNum;
        this.currentPoint.y += (this.targetPoint.y - this.currentPoint.y) * this.factorNum;

        if (Math.abs(this.currentPoint.x - this.targetPoint.x) < this.fractionNum)
            this.currentPoint.x = this.targetPoint.x;
        if (Math.abs(this.currentPoint.y - this.targetPoint.y) < this.fractionNum)
            this.currentPoint.y = this.targetPoint.y;
    }

    addStyle() {
        // 要素にスタイルやスケール等を反映
        Object.assign(this.zoom__elm.style, {
            "transform": `translate(
      ${-this.currentPoint.x * this.baseSize}px,
      ${-this.currentPoint.y * this.baseSize}px
      )
      scale(${this.currentScale})`,
            "transform-origin": `${this.originPoint.x * this.baseSize}px ${this.originPoint.y * this.baseSize}px`
        });

        this.areaScale = 1.0 / this.currentScale;
        Object.assign(this.zoom__area.style, {
            "transform": `translate(
      ${this.currentPoint.x * this.areaSize * this.areaScale}px,
      ${this.currentPoint.y * this.areaSize * this.areaScale}px
      )
      scale(${this.areaScale})`,
            "transform-origin": `${this.originPoint.x * this.areaSize}px ${this.originPoint.y * this.areaSize}px`
        });

        Object.assign(this.zoom__origin01.style, {
            "transform": `translate(
      ${this.originPoint.x * this.baseSize}px,
      ${this.originPoint.y * this.baseSize}px
      )`
        });

        Object.assign(this.zoom__origin02.style, {
            "transform": `translate(
      ${this.originPoint.x * this.areaSize}px,
      ${this.originPoint.y * this.areaSize}px
      )`
        });
    }

    updateInfo() {
        // 確認用テキスト更新
        document.querySelector(".zoom__cp span").innerHTML = `(${this.currentPoint.x.toFixed(2)} : ${this.currentPoint.y.toFixed(2)})`;
        document.querySelector(".zoom__op span").innerHTML = `(${this.originPoint.x.toFixed(2)} : ${this.originPoint.y.toFixed(2)})`;
        document.querySelector(".zoom__scale span").innerHTML = `(${this.currentScale.toFixed(2)})`;
    }
}

結構ボリュームあります。
どこから説明入れていけば分かりやすいか説明下手で何ともなのですが、
見にきてくれてる人いそうなある程度いらっしゃる様子だったらもうちょい整理するかもです。

ではさっそくですが、細かい部分の説明です。

constructor()

使用箇所で説明しますが、一応補足です。

querySelector

document.querySelector(".**")

前回までに散々でてるので一応ですが、querySelectorで要素取得します。

isMouseEnter

this.isMouseEnter = false; // エリア内フォーカスの判定用

これはPC時マウススクロールで拡大縮小するので、
要素上にマウスが乗っているかの判定用です。

baseSizeとareaSize

this.baseSize = 768; // 拡大用画像のサイズとりあえず1:1のサイズのみ
this.areaSize = this.baseSize / 5; // 拡大エリア表示のサイズ

baseSizeはそのまま要素のベースサイズです。
areaSizeは情報確認要素(左上のサムネイル等)エリアのサイズです。
サンプルだと5/1ぐらいのサイズでいいかなーって適当に決めた感じですが、
必要に応じて変える事も可能なハズ。CSS側の調整もいるかもです。

scale系

this.currentScale = 1.0; // スケール初期値・現在値
this.scaleMin = 1.0; // スケール最小値
this.scaleMax = 3.0; // スケール最大値
this.scaleAdd = 0.02; // マウススクロールでのスケール変化値

currentScale
基本的には実際の要素に当てるscaleの数値です。
transform:scale()で要素に当てるスタイルです。

他はコメントそのままなので、追って使用箇所等で補足いれます。

mousewheelevent

this.mousewheelevent = 'onwheel' in document ? 'wheel' : 'onmousewheel' in document ? 'mousewheel' : 'DOMMouseScroll'; //マウスホイールのイベント

mousewheeleventはこれで全てカバー出来ているかは確認できていませんが、
IE等のイベントハンドラの対策です。

normalizePoint(_x, _y)

normalizePoint(_x, _y) {
    // elmRectとbaseSizeに合わせてマウス位置を0.0 ~ 1.0に正規化
    return new Point(
        this.fraction((_x - this.elmRect.left) / this.baseSize),
        this.fraction((_y - this.elmRect.top) / this.baseSize)
    );
}

多分正規化って言い方であってると思っているのですが、
elmRectの座標位置と合わせて要素上の
各Pointの値を正規化しています。

サンプルでの具体的な数値でいうと
ベースサイズが768px*768pxなので
0~768pxのまでの値を0.0~1.0に変換しています。

要素左上が(0.0,0.0)で右下が(1.0,1.0)です。
-0.5=左に384pxです。

言葉にすると難しいですが実際にやってみるとわかる。と思います。
別に必須では無いと思いますが簡単な数値に置き換える事で
考え方等が分かりやすくはずです。

正規化処理は特に今回作成するサンプルでなくても
その他コンテンツの実装でも個人的によく使います。

fraction

ついでの説明になってしまいますが、mouseleave等で中途半端な数値で止まらないように
保険としてfractionで切り上げ切り下げします。

normalizeScale()

normalizeScale() {
    // スケール 値最小/最大値以内に
    if (this.currentScale < this.scaleMin) this.currentScale = this.scaleMin;
    if (this.currentScale > this.scaleMax) this.currentScale = this.scaleMax;
}

constructorで登場したscaleMinとかscaleMaxに合わせてcurrentScaleが範囲内に収まるようにしてます。

サンプルそのままだと、1.0以上~3.0未満の範囲内に収まります。

resetScale()

resetScaleは今回サンプルで言うと
Modalクラスのclose()が呼び出し元です。
※前回コメントしてた部分

モーダル閉じる>開く
って操作をした際に操作したスケールのままだと
違和感があると思うので一回初期スケールにリセットします。

areaScale

this.areaScale = 1.0 / this.currentScale;

areaScaleは拡大エリアのサムネやズームの起点に利用します。

具体的にいうとリセット内での呼び出しは
currentScaleが1.0なのでそのままエリア表示範囲も1.0ですが、

拡大要素が2倍の場合=エリア表示範囲は要素の0.5倍

です。

events()

続いてイベント系です。

window.addEventListener("resize", this.resize.bind(this), false);

// PCイベント
this.zoom.addEventListener("mouseenter", () => { this.isMouseEnter = true; }, false);
this.zoom.addEventListener("mouseleave", () => { this.isMouseEnter = false; }, false);

document.addEventListener("mousedown", this.mousedown.bind(this), false);
document.addEventListener("mousemove", this.mousemove.bind(this), false);
window.addEventListener(this.mousewheelevent, this.mousewheel.bind(this), { passive: false });

// スマホイベント
document.addEventListener("touchstart", this.touchstart.bind(this), false);
document.addEventListener("touchmove", this.touchmove.bind(this), { passive: false });
document.addEventListener("gesturechange", this.gesturechange.bind(this), false);

前回までと同様イベントに関連付けします。

passive: false

{ passive: false }

上記とかはなんか仕様変わったりした?する?可能もあるかもだけど、
chromeとかでエラーでたり、iOSでスクロール禁止にしたりするのに必要です。
詳しく知りたい方はgoogleとかで調べてみてください。

resize()

getBoundingClientRect

getBoundingClientRect()

normalizePoint()で利用してますが、getBoundingClientRectで要素の座標を取得します。
PC位置変更しない場合は必要ないかもですが、
ドラッグやスワイプでの移動等に拡大要素の座標を取得してマウス位置と合わせて利用します。

mousedown()

this.downPoint = new Point(this.currentPoint.x, this.currentPoint.y);

downPointは現ダウン時の現在位置(currentPoint)を記録します。
スライダーの時と同じ仕組みですが移動前の位置を記録しておいて、
差分を計算しドラッグやスワイプでの移動に利用します。

mousemove(e)

if (!this.isMouseEnter) return;

this.pointerPoint = this.normalizePoint(e.clientX, e.clientY);

// マウスドラッグ時の処理
if (EventTouch.instance.isDown)
  this.addDiffPoint();

pointerPointには正規化した座標をいれます。
ドラッグ時にはaddDiffPoint()を実行します。

mousewheel(e)

isMouseEnter

前述のisMouseEnterで判定して要素上にマウスがある場合は拡大縮小、
そうでない場合は普通にウィンドウスクロールします。

EventTouch.instance.mouseDelta(e)

mouseDeltaはEventTouchクラスに定義していますが、
マウススクロールのDelta値を取ります。
上スクロールならばscaleAdd分拡大、下スクロールならばscaleAdd分縮小、です。

touchstart(e)

以降はスマホ(タッチ系)イベント関連の処理がつづきます。

touchstart(e) {
    e = e.originalEvent ? e.originalEvent : e;

    if (e.touches.length > 1) {
        var interpolate = Point.interpolate(EventTouch.instance.touchesStartPoints[0], EventTouch.instance.touchesStartPoints[1], 0.5);
        this.pointerPoint = this.normalizePoint(interpolate.x, interpolate.y);
        if (this.originTimer.isTimeOut) {
            this.updateOrigin();
        }
        this.originTimer.reset();
    } else {
        this.downPoint = new Point(this.currentPoint.x, this.currentPoint.y);
    }
}

downPoint

指が一本の場合はmousedownと同じくスワイプ移動用にdownPointに位置を入れます。

pointerPoint

PCと同じく拡縮に利用する基準点を決める用です。

PCの場合はmousemoveで位置取得していましたが、
SPの場合はマウスが無いので2本の指の位置で基準点を決めます。

Point.interpolate

Pointクラスに話が行ってしまいますが
Point.interpolateは二点間の補間座標を返します。
2本の指の位置と補間点(0.5で中間)です。

※数学とか物理強くないので、用語間違ってるかも

touchmove(e)

addDiffPointはPCと同じですが、
指が2本以上の場合はreturnしてます。

gesturechange()

gesturechangeはタッチデバイスで複数の指を動かした時に実行されます。

PCはマウススクロールで拡縮してましたが
SPの場合はピンチインピンチアウトで拡縮します。

currentScaleにEventTouch.instance.scaleを入れてます。

EventTouch.instance.scale

話はEventTouchクラスにいってしまうのですが、

iOSの場合はgesturechangeでscaleっていうプロパティがデフォルトでとれるのですが
androidにはありません。ので自前で実装します。

onGestureChange(e) {
    this.originalEvent(e);
    let scale = 1;
    if (e.scale) {
        scale = e.scale;
    } else {
        let startDistance = Point.distance(this.touchesStartPoints[0], this.touchesStartPoints[1]);
        let currentDistance = Point.distance(this.touchesCurrentPoints[0], this.touchesCurrentPoints[1]);
        scale = currentDistance / startDistance;
    }
    this.scale = scale;
}

Point.distance

Point.distanceは二点間の距離を求めます。(三平方の定理、であってると思う)

目標の拡大スケール = 現在の距離/スタート時の距離

です。

【拡大機能付きスライダー】javascript実装サンプル第5回 まとめと次回

やっぱり長くなりそうなので、今回はイベント系の処理までと言うことで
前半後半にわけます。

次回は今回の各イベントで変更した値なのをupdateで実際に要素を更新するところの説明をします。
下手な説明ですが、おいおいもうちょっとまとめてつつ更新します。

【拡大機能付きスライダー】javascript実装サンプルの連載一覧

  1. [1]初心者の方向けに作成手順や説明等していきます
  2. [2]ドラッグ値/スワイプ値を取得する
  3. [3]スライダーを作成する
  4. [4]モーダルを作成する
  5. [5]拡大(ズーム)機能を作成する
  6. [6]拡大(ズーム)機能を作成する②

本連載に関して

本連載では、初心者向けに各所の解説をしていますが、
一歩踏み込んだ実践的な内容にはなっていると思います。
※フロントエンド(HTML/CSS/JavaScript)の基礎知識は必須になります。