[Javascript] ホバーでサムネイル画像を原寸拡大するビューアー


経緯

昔実験的なコードを置いてた某サイトが繋がらなくなって、Codepenに引っ越ししました。
最近、CodepenのpenをQiitaに張り付けられることを知ったので、ついでに解説を付けてみようと思いました。

サムネイル画像にマウスを重ねると、原寸サイズの画像が表示されます。フレーム内のマウスカーソル位置にあわせてスクロールします。
簡素な作りかつ省スペースで、必要に応じて全体画像と原寸画像を切り替えられるようにしてみました。

See the Pen Simple Image Viewer 簡易ビューアー by ShinodaNaoki (@shinodanaoki) on CodePen.

解説

DOM構成

<div class="viewport">
  <div class="wrapper">
    <img src="..."/>
  </div>
</div>

HTMLは原寸大の画像をセットした img タグ、それを囲む wrapper クラスの付いた div はスクロールペインの代わりで、img タグの相対位置を動かします。 さらにその外側の viewport クラスの付いた div はオリジナル画像を縮小して全体を表示するためのエリアであり、同時に内側の原寸大画像の表示領域をクリップします。必要に応じて border などのデザインを付けることを想定しています。

スタイルシート

viewport

div.viewport {
  width: 300px;
  height: 200px;
  overflow: hidden;
  position: relative;
  background: #888;
  border: solid 25px #000;
  background-size: contain;
  background-position: center center;
  background-repeat: no-repeat;
}

width, height はお好みで。あとで javascript から img タグと同じ画像を設定するため、css には background-image を記述してませんが、他の背景用スタイルを設定してます。
javascript が border を座標計算範囲から除外できていることを確認しやすくするため、敢えて太めの border を設定しています。

wrapper

div.wrapper {
  position: absolute;
  width: auto;
  height: auto;
  background: #000;
  margin: 0;
  padding: 0;
}

スクロール移動するための position: absolute を指定し、width, height は img コンテンツに合わせるため auto にしてます。

JavaScript

ローカル変数 pad

viewport 内のマウス座標に比例してスクロールする周囲を指定ピクセル分狭めるための設定値です。

  // padding around viewport where no more image scroll.
  var pad = 24;

これがないと、原寸大画像の一番端を見るためには、マウスカーソルを viewport 枠のギリギリまで動かさなければならなくなり(枠の外にでると原寸画像は消えてしまうため)、精密な操作が要求されてしまいます。より快適な使用感を得られるように、ある程度 viewport 枠に近づければ原寸大画像の端までスクロールできるようにするための調整値です。

pad=24 pad=0

上の画像ではわかりやすくするため viewport の border をなくしてます。 border をつければ、そこでもマウスイベントを拾うので pad=0 でも多少マシになります。しかし、自由にデザインしたいので border なくても使える方がいいに決まってますよね。

onMouseMove

マウスを動かした時のスクロール、今回の要です。

まず、viewport 内でのカーソルの相対座標の計算部分ですが・・・

    var bx = parseFloat($view.css("border-left-width"));
    var by = parseFloat($view.css("border-top-width"));
    var ox = $view.offset().left;
    var oy = $view.offset().top;
    // px/py = border eliminated offset position in viewport.
    var px = e.clientX - ox - bx - pad;
    var py = e.clientY - oy - by - pad;

jQueryを使っても、border なしの相対座標は取れなさそうだったので、bx, by で border 幅を取得し、ox, oy でDOM要素の相対座標を取得し、マウスイベントの clientX, clientY から引くことで border を含まない viewport 上での相対カーソル座標 (px, py) を計算しています。
さらに pad 分も引くことで、左と上の計算余白分を確保しています。

次に、px, py を0~1の相対比率に変換します。

    var vh = $view.innerHeight();
    var vw = $view.innerWidth();
    // rx/rw = 0 to 1 propotional position where scroll to.
    var rx = Math.min(1,Math.max(px/(vw - pad*2),0));
    var ry = Math.min(1,Math.max(py/(vh - pad*2),0));

要するに、 viewport の実サイズで割るだけなんですが、ここでも pad*2 を引くことで右と下の計算余白分を確保します。(左と上は座標をずらしてるので、その分も引く必要がある)
計算余白分のせいで 0~1 の範囲からはみ出す分を min, max で範囲内に調整しています。

もし min, max の調整をしなければ、計算余白内まで枠に近づくと原寸大画像の端を超えてスクロールして、背後の縮小画像が見えてしまいます。

調整あり 調整なし

最後に、wrapper を動かす位置を計算します。

    var iw = $img.width();
    var ih = $img.height(); 
    var x = ((iw - vw) * rx).toFixed(2);
    var y = ((ih - vh) * ry).toFixed(2);

rx, ry に画像の実サイズを掛ければいいのですが、この時 viewport のサイズを引いているのに注意してください。なぜかと言えば、カーソルが左上隅にいる時は、viewportの左上位置はimgの(0,0) を指してほしいですが、カーソルが右下隅にいる時はviewportの左上位置は(iw - vw, ih - vh) になってほしいからです【下図参照】。仮に viewport の左上位置がimgの (iw, ih) を指すように設定すると、画像はviewportの左上に隠れてしまいますよね?

最後は wrapper を目的の座標に動かして完了です。

    var $wrp = $(".wrapper");
    $wrp.css("top", -y+"px").css("left", -x+"px");

viewport の左上位置がimgの (x, y) を指すようにする、ということは動かない viewport の代わりに img = wrapper を (-x, -y) に動かすということですね。

onMouseEnter, onMouseExit

マウスが viewport に入ってきたら img を表示し、外れたら非表示にするだけです。
img が隠れると、 viewport の背景に設定した縮小画像が見える仕掛けです。