気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

縮小表示した画像をズームするJavaScript「MagnifyImageJS」

f:id:AonaSuzutsuki:20200819135223p:plain

ふとポートフォリオ作ってたら大きな解像度の画像が潰れてしまうのが気になってしまい、Amazonみたいにマウスオーバーでオリジナル画像表示できないかなーと作ってみたシリーズ。
拡大って書いてるけど、2倍表示とか倍率には対応してないあくまでオリジナルを表示するだけなのでその点はご容赦ください。
なお、ここでは便宜上ズームと表記するのでご注意ください。

検証環境

項目 詳細
Google Chrome 84.0.4147.125, 64bit
Safari 13.0.5 (15608.5.11)
Vue.js v2.6.11

サンプル

f:id:AonaSuzutsuki:20200819141502g:plain

サンプル - 単一表示
サンプル - 複数表示

使い方

ダウンロード

Github
Github - master.zip

展開すると出てくる「min/MagnifyImageJS.min.js」を適当にコピーして、HTMLでインポートすればOK。

HTML

まずはHTMLの定義から。
img要素の #current-src はサムネイルというか、縮小表示する画像です。容量の大きな画像を使う場合は、srcにサムネイル用の画像をセットし、data-origにオリジナル画像をセットするとマウスオーバー時はオリジナル画像が表示されます。
div要素の #img-zoom-lens はマウスオーバー時に出てくる今どこが表示されているかの枠に使います。
div要素の #current-src-hover はズームした際の画像を表示するのに使用します。
相変わらず命名が下手くそなのはボキャ貧なだけなので許して...。

注意点としては、縮小表示するimg要素(#current-src)とマウスオーバー時に出てくる四角い枠(#img-zoom-lens)は一つのコンテナにまとめます
CSS次第ではありますが、こうしておかないと今回の定義では正しくマウスオーバーの枠が表示されなくなります。

また、最近Vue.js使い出したから使ってるけど、ライブラリ自体はVue.jsに依存してないのでjQueryでもなんでも好きなやつ使ってもらって大丈夫です。
MouseOverとMouseLeave、MouseMoveイベントさえ割り当てられればOK。

<div id="container">
    <div id="display-image-container">
        <div id="img-zoom-lens" v-bind:class="{
            visible: visibility }"></div>
        <img id="current-src" :src="displaySrc" :data-orig="originalSrc" v-on:mouseover="currentImageMouseOver"
            v-on:mouseleave="currentImageMouseLeave" v-on:mousemove="currentImageMouseMove" />
    </div>

    <div id="current-src-hover" v-bind:class="{
        visible: visibility }">
    </div>
</div>

CSS

お次はCSSです。
こっちも2つ注意点があって、1つ目が非表示です。
要素を非表示にするときはしばしば「display: none」を使いますが、これを使うとclientWidth, clientHeightが0になってうまく計算できなくなるのでvisibilityを使います

もう一つは、HTMLでも説明した通り、img要素と四角い枠のコンテナ(#display-image-container)のpositionをrelativeにします。
これにより四角い枠がコンテナ内での絶対位置となるため実質的に縮小画像上の絶対位置となります。

あとは適度に弄ってサイトに合わせてください。

.visible {
    visibility: visible !important;
}

#container {
    display: flex;
}

#current-src {
    width: 400px;
}

#display-image-container {
    position: relative;
}

#img-zoom-lens {
    visibility: hidden;
    pointer-events: none;
    position: absolute;
    border: 2px solid #e45b9f;
    box-sizing: border-box;
}

#current-src-hover {
    visibility: hidden;
    width: 400px;
    height: 400px;
    border: 1px solid;
}

JavaScript

最後にJavaScriptです。
面倒なので割愛しますが、MagnifyImageJS.min.jsをインポートしておきましょう。

まずはMagnifyImageJSインスタンスを生成します。
この時のそれぞれの引数は以下の通りです。
第一引数は縮小表示されている画像のID
第二引数はオリジナル画像を表示するdivなどの要素のID
第三引数はマウスオーバー時の四角い枠のID

let imageZoom = new MagnifyImageJS("current-src", "current-src-hover", "img-zoom-lens");

次にVueインスタンスを生成してMouseOver, MouseLeave, MouseMoveイベントをそれぞれ定義します。

MouseOverでは「MagnifyImageJS#setHoverImageメソッド」を呼び出すことでズーム画像をセットします。
MagnifyImageJS#canZoomメソッド」ではズーム可能かどうか、要するにオリジナル画像がズーム要素の枠に収まるかどうかを取得できます。
収まればズーム不要なので非表示にしちゃいます。

MouseLeaveでは非表示にするだけ。

MouseMoveでは「MagnifyImageJS#refreshHoverImage(event)メソッド」を呼び出す事でズーム要素の更新と四角い枠の位置を更新します。
なお、あんまりないと思いますがマウスオーバー中に画像サイズが変わるような場合はリフレッシュ前に「MagnifyImageJS#calculateRatiosメソッド」を呼び出してください。

new Vue({
    el: "#container",
    methods: {
        currentImageMouseOver: async function () {
            await imageZoom.setHoverImage();
            this.visibility = imageZoom.canZoom();
        },
        currentImageMouseLeave: function () {
            this.visibility = false;
        },
        currentImageMouseMove: function (event) {
            imageZoom.refreshHoverImage(event);
        }
    },
    data: {
        visibility: false,
        displaySrc: "thumbnails/1.jpg",
        originalSrc: "images/1.png"
    }
});

コードの内容

座標の変換

この手の実装で厄介なのが表示サイズとオリジナルサイズ、はたまたズーム要素のサイズでそれぞれ座標が違うところです。
どういうことかと言うと、img要素から得られる座標は縮小されているのでそのままではズーム時のBackgroundPositionの移動量として使う事ができません
そこで各座標を変換することで対応します。

二次元の座標変換は数学などで触れられると思いますが、ここでは単純な縮小・拡大を用います

座標の拡大縮小は( x, y)の点がある時、それぞれ s_x, s_y倍すると拡大縮小した点( x', y')が得られます。
f:id:AonaSuzutsuki:20200819140811p:plain

なお、拡大したい場合は s_x \gt 1 ( s_y \gt 1)、縮小したい場合は 0 \lt s_x \lt 1 ( 0 \lt s_y \lt 1)となります。

ズーム画像の表示

オフセットなしの表示

といったように座標変換をするのですが、マウス座標を中央にして拡大を行うにはオフセットを加算する必要があります。
それも混ぜて考えると厄介なので、まずは座標変換だけを行ってマウス座標を左上にあるものとして表示してみます。

左上座標 サンプル - jsfiddle

マウス座標を取得するgetCursorPos関数はHow To Create an Image Zoomより拝借しました。

今回使用する画像だと、縮小表示とオリジナルでは742 × 428と1856 × 1070なのでかなり大きな差があります。
縮小表示上のマウス座標、例えば下図だと右下をこれだけズラす必要があります。

f:id:AonaSuzutsuki:20200819140827j:plain

そこで先ほどの座標変換を使うのですが、まずは倍率を計算する必要があります。
倍率は簡単で、オリジナル画像の横幅・縦幅( x_o, y_o)と縮小画像の横幅・縦幅( x_t, y_t)を割ります
f:id:AonaSuzutsuki:20200819140842p:plain

今回だと、1856 × 1070と742 × 428なので以下の通り。
f:id:AonaSuzutsuki:20200819140855p:plain

この倍率が取れたらあとは任意の縮小画像の座標にそれぞれ s_x, s_yをかけてあげればオリジナルの座標が得られます。

それを簡単なコードに直すと以下のようになります。

let img = document.getElementById("current-src");
let originalImg = new Image();
originalImg.onload = function () {
    let pos = getCursorPos(event);
    let toRatio {
        widthRatio: originalImg.width / img.clientWidth,
        heightRatio: originalImg.height / img.clientHeight
    };
    let calcX = pos.x * toRatio.widthRatio;
    let calcY = pos.y * toRatio.heightRatio;

    // change backgroundPosition
};
originalImg.src = img.src;

これで縮小画像上のマウス座標はオリジナル画像上の座標(calcX, calcY)に変換されます。
ただ、Image#onloadイベントは制御し辛いので実際に使う時はPromiseオブジェクトとasync/awaitを使ったほうがスッキリ書けると思います。
サンプルではそちらを使っています。

これで座標が手に入ったので後はCSSのBackgroundPositionを変更するだけです。
ただし、それぞれの数値は負数にします
BackgroundPositionは指定した位置を基準に「画像を動かす」ので、負数でないと逆に動いてしまいます。

let result = document.getElementById("current-src-hover");
result.style.backgroundPosition = `top ${-calcX}px left ${-calcY}px`;

オフセットを使った中央表示

オフセットなしができればあとはオフセットを加えてあげればマウスを中央にした状態で表示することができます。

中央座標 サンプル - jsfiddle

オフセットの計算は簡単で、オリジナル画像を表示する結果画面のサイズを2で割った数値を(x, y)にそれぞれ引いてあげればできます。

let img = document.getElementById("current-src");
let result = document.getElementById("current-src-hover");
let originalImg = new Image();
originalImg.onload = function () {
    let offsetWidth = result.clientWidth / 2;
    let offsetHeight = result.clientHeight / 2;
    let pos = getCursorPos(event);
    let toRatio {
        widthRatio: originalImg.width / img.clientWidth,
        heightRatio: originalImg.height / img.clientHeight
    };
    let calcX = pos.x * toRatio.widthRatio - offsetWidth;
    let calcY = pos.y * toRatio.heightRatio - offsetHeight;

    // change backgroundPosition
};
originalImg.src = img.src;

ただし、注意点があって結果画面をCSSで「display: none」を指定しているとclientWidthとclientHeightが0になってしまいます
そのためもし隠すなら「visibility: hidden」を使いましょう。

マウスオーバー時の四角枠の表示

次にマウスオーバー時の四角い枠を表示させます。

四角い枠 サンプル - jsfiddle

ここでまたしても問題になるのが座標の変換です。
計算方法は上述したものと同様ですが、オリジナル画像から縮小画像への変換となるため、今度は逆の倍率を取得します。
f:id:AonaSuzutsuki:20200819140915p:plain

これが取得できたらまずは四角い枠のサイズを設定します。
四角いサイズは結果画面の横幅と縦幅に先ほどの倍率をかけたものとなります。

let img = document.getElementById("current-src");
let result = document.getElementById("current-src-hover");
let lens = document.getElementById("img-zoom-lens");

let fromRatio = {
    widthRatio: img.clientWidth / newImg.width,
    heightRatio: img.clientHeight / newImg.height
};

lens.style.width = `${result.clientWidth * fromRatio.widthRatio}px`;
lens.style.height = `${result.clientHeight * fromRatio.heightRatio}px`;

次に、マウス座標から四角い枠の座標を計算します。
こちらはマウス座標に関してはそのまま使用する事ができますが、オフセットは倍率をかけて変換する必要があります。
あとは変換後のオフセットを引けば座標の完成です。

let img = document.getElementById("current-src");
let result = document.getElementById("current-src-hover");
let originalImg = new Image();
originalImg.onload = function () {
    let offsetWidth = result.clientWidth / 2;
    let offsetHeight = result.clientHeight / 2;
    let pos = getCursorPos(event);
    let fromRatio = {
        widthRatio: img.clientWidth / newImg.width,
        heightRatio: img.clientHeight / newImg.height
    };
    let lensX = pos.x - offsetWidth * fromRatio.widthRatio;
    let lensY = pos.y - offsetHeight * fromRatio.heightRatio;

    // change lens position
};
originalImg.src = img.src;

あとはできた座標を四角い枠のleftとtopにそれぞれ設定してあげれば完了です。

lens.style.left = `${lensX}px`;
lens.style.top = `${lensY}px`;

範囲外の判定

最後に範囲外に出ないように判定を追加します。
現段階では結果が画像範囲外に出ても移動してしまいます。

範囲外の判定 サンプル - jsfiddle

左端と上端の判定は簡単、計算後の(x, y)が0よりも小さくなれば背景画像の移動量も四角い枠の移動量も0にすればOK。

if (calcX < 0) {
    calcX = 0;
    lensX = 0;
}
if (calcY < 0) {
    calcY = 0;
    lensY = 0;
}

右端と下端は、マウス座標に結果画面のサイズを足した値がオリジナル画像のサイズを超えるかどうかで判定します。
判定できたら背景画像の移動量を「オリジナル画像のサイズ - 結果画面のサイズ」とします。
四角い枠に関しては「縮小画像のサイズ - 結果画面のサイズ * 倍率」とします。
倍率は上述した通り、縮小画像からオリジナル画像を割った数値です。

if (calcX + result.clientWidth >= imageSize.width) {
    calcX = imageSize.width - result.clientWidth;
    lensX = img.clientWidth - result.clientWidth * fromRatio.widthRatio;
}
if (calcY + result.clientHeight >= imageSize.height) {
    calcY = imageSize.height - result.clientHeight;
    lensY = img.clientHeight - result.clientHeight * fromRatio.heightRatio;
}