input要素に対してダイヤル/リングの回転操作で数値入力するUI

前回の[針を直接回して時刻入力するタイムピッカー](https://qiita.com/akebi_mh/items/662da8061f588d0afe38)の**つまんで回転させる動作**を流用して作成した、数値を扱えるタイプの**input要素**に対して回転動作で入力するためのUIです。
操作感覚は映像編集関連入力機器のジョグシャトルをイメージしています。

![image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/76119570-fd4d-9ac0-8ee6-258845a136a6.jpeg)

実物のジョグシャトル(ジョグダイヤル及びシャトルリング)は主に映像編集関連機器でよく使われる、編集ポイントに移動するための送り戻しを自在に行うための入力装置です。

###設定方法###
`script`タグで`jogshuttle.js`を読み込み、適用させたいinputタグに`data-jogshuttle`属性を追記します。

**基本設定**

~~~html
<script src='jogshuttle.js'></script>
<input type='text' data-jogshuttle>
~~~

[**動作サンプル**](https://akebi.jp/temp/jogshuttle/example.html)

###設定項目###

####inputタグ自体の属性####
適用させるinputタグに`min`、`max`、`step`属性があれば、UI側でもその値を使用します。

####data-jogshuttle属性で設定できる項目####
`data-jogshuttle`には、以下の項目が指定できます。
**size** UIのサイズ(px) デフォルトは200px
**jog-color** ジョグダイヤルの枠色
**jog-background** ジョグダイヤルの背景色
**shuttle-color** シャトルリングの枠色
**shuttle-background** シャトルリングの背景色
**jog-ratio** UI全体に対するジョグダイヤルのサイズ比(0～1) デフォルト 0.7
0にすると実質シャトルリングのみ、1にするとジョグダイヤルのみとして扱うことができます。
**oninput** 適用元要素にoninputイベントが存在する場合、UIの数値が変化するたびに適用元要素の`oninput()`を発火させます。
**onchange** 適用元要素にonchangeイベントが存在する場合、UIの数値を変化させ離したタイミングで適用元要素の`onchange()`を発火させます。
対象要素に`onchange`や`oninput`が設定されていてもその要素のvalueをJavaScriptで変更しただけでは発火しないため、元要素に対して`onchange()`や`oninput()`メソッドを実行することで発火させています。
**target-id** 別要素のidを指定することで、値を変更する対象の要素を変更することができます。

[**各設定動作サンプル**](https://akebi.jp/temp/jogshuttle/)

###スクリプト###

~~~js:jogshuttle.js
"use strict";
{
    const
        jsParam = {
            styles: {},
            ids: {},
            attr: {},
            displayFlg: 0,
        },
        d = {},
        jsHandler = (function() {
            const events = {};
            let key = 1;
            return {
                add: function(target, type, listener, capture) {
                    target.addEventListener(type, listener, capture);
                    events[key] = {
                        target: target,
                        type: type,
                        listener: listener,
                        capture: capture
                    };
                    return key++;
                },
                remove: function(key) {
                    if(key in events) {
                        const e = events[key];
                        e.target.removeEventListener(e.type, e.listener, e.capture);
                        delete(events[key]);
                    }
                }
            };
        }());

    function jsSetStyle() {
        const size = jsParam.attr['size'] === undefined ? 200 :
            Math.abs(parseInt(jsParam.attr['size']));

        // 外枠
        let ds = d.jogshuttle.style;
        ds.width = size + 'px';
        ds.height = size + 'px';
        ds.position = 'absolute';
        ds.cursor = 'move';
        ds.left = '0px';
        ds.top = '0px';
        ds.overflow = 'hidden';

        // shuttle ring
        ds = d.shuttle.style;
        ds.position = 'absolute';
        ds.width = size + 'px';
        ds.height = size + 'px';
        ds.left = '0px';
        ds.top = '0px';
        ds.border = '1px solid ' + (jsParam.attr['shuttle-color'] !== undefined ?
            jsParam.attr['shuttle-color'] : '#888');
        ds.background = (jsParam.attr['shuttle-background'] !== undefined ?
            jsParam.attr['shuttle-background'] : '#ddd');
        ds.cursor = 'pointer';
        ds.borderRadius = '50%';
        ds.transform = 'rotate(180deg)';
        ds.boxSizing = 'border-box';
        ds.userSelect = 'none';
        ds.WebkitUserSelect = 'none';

        ds = d.shuttle.querySelector('.jsRefPosition').style;
        ds.position = 'absolute';
        ds.width = (size * 0.01) + 'px';
        ds.height = (size * 0.25) + 'px';
        ds.left = (size / 2 - size * 0.005 / 2) + 'px';
        ds.bottom = '0px';
        ds.background = (jsParam.attr['shuttle-color'] !== undefined ?
            jsParam.attr['shuttle-color'] : '#888');
        ds.boxSizing = 'border-box';
        ds.userSelect = 'none';
        ds.WebkitUserSelect = 'none';

        // jog dial
        let n = Math.abs(parseFloat(jsParam.attr['jog-ratio']));
        const jogRatio = jsParam.attr['jog-ratio'] === undefined ? 0.7 :
            (n > 1 ? 1 : n < 0 ? 0 : n);

        ds = d.jog.style;
        ds.position = 'absolute';
        ds.display = (jogRatio === 0) ? 'none' : 'block';
        ds.width = (size * jogRatio) + 'px';
        ds.height = (size * jogRatio) + 'px';
        ds.left = (size / 2 - size * jogRatio / 2) + 'px';
        ds.top = (size / 2 - size * jogRatio / 2) + 'px';
        ds.border = '1px solid ' + (jsParam.attr['jog-color'] !== undefined ?
            jsParam.attr['jog-color'] : '#888');
        ds.background = (jsParam.attr['jog-background'] !== undefined ?
            jsParam.attr['jog-background'] : '#ddd');
        ds.cursor = 'pointer';
        ds.borderRadius = '50%';
        ds.transform = 'rotate(0deg)';
        ds.boxSizing = 'border-box';
        ds.userSelect = 'none';
        ds.WebkitUserSelect = 'none';

        ds = d.jog.querySelector('.jsRefPosition').style;
        ds.position = 'absolute';
        ds.width = '20%';
        ds.height = '20%';
        ds.left = '40%';
        ds.top = '10%';
        ds.border = '1px solid ' + (jsParam.attr['jog-color'] !== undefined ?
            jsParam.attr['jog-color'] : '#888');
        ds.borderRadius = '50%';
        ds.boxSizing = 'border-box';
        ds.userSelect = 'none';
        ds.WebkitUserSelect = 'none';

        // フォーカス制御用ダミー
        ds = d.dummy.style;
        ds.position = 'absolute';
        ds.width = '1px';
        ds.left = '-999px';
        if(jsParam.touchFlg) d.dummy.type = 'checkbox';
    }

    window.addEventListener('DOMContentLoaded', function() {
        document.querySelector('body').insertAdjacentHTML('beforeend',
            "<div id='jogshuttle' hidden>" +
            "    <input type='text' id='jsDummy'>" +
            "    <div id='jsShuttle'>" +
            "        <div class='jsRefPosition'></div>" +
            "    </div>" +
            "    <div id='jsJog'>" +
            "        <div class='jsRefPosition'></div>" +
            "    </div>" +
            "</div>"
        );

        d.jogshuttle    = document.querySelector('#jogshuttle');
        d.dummy         = document.querySelector('#jogshuttle #jsDummy');
        d.shuttle       = document.querySelector('#jogshuttle #jsShuttle');
        d.jog           = document.querySelector('#jogshuttle #jsJog');

        d.jogshuttle.style.display = 'none';

        jsParam.touchFlg = window.ontouchstart !== undefined ? true : false;
        jsSetStyle();

        let r, r_, x, y, sx, sy,
            shuttleFlg = false,
            jogFlg = false,
            moveFlg = false,
            start;

        window.addEventListener(jsParam.touchFlg ? 'touchstart' : 'mousedown', function() {
            jsParam.clickStart = true;
        });

        // 枠クリック
        d.jogshuttle.addEventListener(jsParam.touchFlg ? 'touchstart' : 'mousedown', function(e) {
            if(!moveFlg) {
                if(jsParam.touchFlg) jsAddHandler();
                moveFlg = true;
                sx = (jsParam.touchFlg ? e.touches[0].clientX : e.clientX) - parseInt(this.style.left);
                sy = (jsParam.touchFlg ? e.touches[0].clientY : e.clientY) - parseInt(this.style.top);
            }
        });

        // shuttleクリック
        d.shuttle.addEventListener(jsParam.touchFlg ? 'touchstart' : 'mousedown', function(e) {
            if(!shuttleFlg) {
                if(jsParam.touchFlg) jsAddHandler();
                shuttleFlg = true;
                jsParam.shuttleValue = 0;
                jsStartSendShuttle();

                const st = d.shuttle.style.transform;
                r_ = r = (parseInt(st.replace(/^[^\d-]+/g, '')) + 360 ) % 360;

                const js = d.jogshuttle;
                const ex = jsParam.touchFlg ? e.touches[0].clientX : e.clientX;
                const ey = jsParam.touchFlg ? e.touches[0].clientY : e.clientY;
                x = ex - (js.offsetLeft - window.pageXOffset) - (js.clientWidth / 2);
                y = ey - (js.offsetTop - window.pageYOffset) - (js.clientHeight / 2);
                const at = r_ = Math.atan2(y, x);
                start = ((at * (360 / (Math.PI * 2))) + 270) % 360;
                setTimeout(function() { d.dummy.focus() }, 10);
            }
        });

        // jogクリック
        d.jog.addEventListener(jsParam.touchFlg ? 'touchstart' : 'mousedown', function(e) {
            if(!jogFlg) {
                if(jsParam.touchFlg) jsAddHandler();
                jogFlg = true;
                const st = d.jog.style.transform;
                r_ = r = (parseInt(st.replace(/^[^\d-]+/g, '')) + 360 ) % 360;

                const js = d.jogshuttle;
                const ex = jsParam.touchFlg ? e.touches[0].clientX : e.clientX;
                const ey = jsParam.touchFlg ? e.touches[0].clientY : e.clientY;
                x = ex - (js.offsetLeft - window.pageXOffset) - (js.clientWidth / 2);
                y = ey - (js.offsetTop - window.pageYOffset) - (js.clientHeight / 2);
                const at = Math.atan2(y, x);
                start = ((at * (360 / (Math.PI * 2))) + 450 - r) % 360;
                jsParam.ids['inertia'] = 0;
                jsParam.attr.diff = 0;
                setTimeout(function() { d.dummy.focus() }, 10);
            }
        });

        // ドラッグ
        window.addEventListener(jsParam.touchFlg ? 'touchmove' : 'mousemove', function(e) {
            const js = d.jogshuttle;
            jsParam.clickStart = false;

            if(shuttleFlg || jogFlg || moveFlg) {
                const ex = jsParam.touchFlg ? e.touches[0].clientX : e.clientX;
                const ey = jsParam.touchFlg ? e.touches[0].clientY : e.clientY;
                x = ex - (js.offsetLeft - window.pageXOffset) - (js.clientWidth / 2);
                y = ey - (js.offsetTop - window.pageYOffset) - (js.clientHeight / 2);

                const at = r = Math.atan2(y, x);
                let f = (r - r_) > 0 ? 1 : -1;

                if(r - r_ > Math.PI) r_ += Math.PI * 2;
                else if(r - r_ < -Math.PI) r_ -= Math.PI * 2;

                if(shuttleFlg) {
                    const diff = Math.abs(r - r_) * f;
                    const dd = diff * (360 / (Math.PI * 2));
                    let n = (parseFloat(d.shuttle.style.transform.replace(/^[^\d-]+/g, '')) + dd);
                    n = (n > 330) ? 330 : (n < 30) ? 30 : n;
                    d.shuttle.style.transform = 'rotate(' + n + 'deg)';
                    jsParam.shuttleValue = (n - 180) / 150;
                }
                else if(jogFlg) {
                    const k = ((at * (360 / (Math.PI * 2))) + 810 - start) % 360;
                    d.jog.style.transform = 'rotate(' + k + 'deg)';
                    jsParam.attr.diff = k - jsParam.k_;
                    if(jsParam.attr.diff > 180) jsParam.attr.diff -= 360;
                    else if(jsParam.attr.diff < -180) jsParam.attr.diff += 360;
                    jsParam.valueOrigin = Number(jsParam.valueOrigin) +
                        Number(jsParam.attr.diff / (360 / 20)) * jsParam.attr.step;
                    jsRangeCheck();
                    jsParam.k_ = k;
                }
                else {
                    d.jogshuttle.style.left = (ex - sx) + 'px';
                    d.jogshuttle.style.top = (ey - sy) + 'px';
                }
                r_ = r;
            }
        });

        // リリース
        window.addEventListener(jsParam.touchFlg ? 'touchend' : 'mouseup', function() {
            if(shuttleFlg) {
                shuttleFlg = false;
                jsParam.shuttleValue = 0;
                d.shuttle.style.transform = 'rotate(180deg)';
                jsStopSendShuttle();
            }
            if(jogFlg) {
                jogFlg = false;
                if(Math.abs(jsParam.attr.diff) > 10) {
                    jsParam.attr.diff *= 10;
                    jsParam.ids['inertia'] = 1;
                    jsJogInertia();
                }
            }
            if(moveFlg) {
                moveFlg = false;
            }

            if(d.jogshuttle.style.display !== 'none') {
                d.dummy.focus();
                if(jsParam.attr.onchange !== undefined && jsParam.o.onchange && jsParam.attr.onchange)
                    jsParam.o.onchange();
            }

            if(jsParam.touchFlg) jsRemoveHandler();

            if(d.jogshuttle.style.display !== 'none') {
                setTimeout(function() {
                    if(jsParam.clickStart === true) {
                        d.jogshuttle.style.display = 'none';
                        jsRemoveHandler();
                        jsParam.attr.diff = 0;
                    }
                }, 50);
            }
        });

        // マウスホイール
        window.addEventListener('wheel', function(e) {
            if(!jsParam.ids['wheel']) return;
            const delta = e.wheelDelta !== undefined ? e.deltaY / 40 : e.deltaY / 20;
            jsParam.valueOrigin = jsParam.valueOrigin + (delta / 4) * jsParam.attr.step;
            jsRangeCheck();

            const n = parseFloat(d.jog.style.transform.replace(/^[^\d-]+/g, '')) + delta * 4;
            d.jog.style.transform = 'rotate(' + (360 + n) % 360 + 'deg)';
        });

        // dataset(data-jogshuttle)指定のあるinput要素にclickイベントを追加
        const inputElements = document.querySelectorAll('input[data-jogshuttle]');
        for(let i in inputElements) {
            if(inputElements[i].dataset === undefined) continue;
            const dataset = inputElements[i].dataset.jogshuttle;
            if(dataset === undefined) continue;
            inputElements[i].addEventListener('click', function() {
                jsJogshuttle(this);
            });
        }
    });

    function jsAddHandler() {
        if(!jsParam.ids['touchmove']) {
            jsParam.ids['touchmove'] = jsHandler.add(window, 'touchmove', function(e) {
                e.preventDefault();
            }, { passive: false });
        }
        if(!jsParam.ids['wheel']) {
            jsParam.ids['wheel'] = jsHandler.add(window, 'wheel', function(e) {
                e.preventDefault();
            }, { passive: false });
        }
    }

    function jsRemoveHandler() {
        if(jsParam.ids['touchmove'] > 0) {
            jsHandler.remove(jsParam.ids['touchmove']);
            jsParam.ids['touchmove'] = 0;
        }
        if(jsParam.ids['wheel'] > 0) {
            jsHandler.remove(jsParam.ids['wheel']);
            jsParam.ids['wheel'] = 0;
        }
    }

    function jsStartSendShuttle() {
        if(jsParam.ids['send'] === undefined || jsParam.ids['send'] === 0) {
            jsParam.ids['send'] = 1;
            jsSendShuttleValue();
        }
    }

    function jsStopSendShuttle() {
        if(jsParam.ids['send'] !== undefined && jsParam.ids['send'] !== 0)
            jsParam.ids['send'] = 0;
    }

    function jsSendShuttleValue() {
        jsParam.valueOrigin = Number(jsParam.valueOrigin) +
            Math.pow(jsParam.shuttleValue * 9, 3) * jsParam.attr.step;
        jsRangeCheck();
        if(jsParam.ids['send']) requestAnimationFrame(jsSendShuttleValue);
    }

    function jsRangeCheck() {
        if(jsParam.attr.min !== undefined) {
            if(jsParam.valueOrigin < jsParam.attr.min)
                jsParam.valueOrigin = jsParam.attr.min;
        }

        const rs = 1 / jsParam.attr.step;
        let value = Math.floor(jsParam.valueOrigin * rs) / rs;

        if(jsParam.attr.min !== undefined) value +=
            (jsParam.attr.min % jsParam.attr.step);

        if(jsParam.attr.max !== undefined) {
            if(value > jsParam.attr.max) value = jsParam.attr.max;

            if(jsParam.valueOrigin > jsParam.attr.max + jsParam.attr.step)
                jsParam.valueOrigin = jsParam.attr.max + jsParam.attr.step;
        }

        if(jsParam.attr.min !== undefined && value < jsParam.attr.min)
            value = jsParam.attr.min;

        if(jsParam.attr.stepDecDigit)
            value = parseFloat(Number(value).toFixed(jsParam.attr.stepDecDigit))

        jsParam.target.value = value;

        if(jsParam.attr.oninput !== undefined && jsParam.o.oninput && jsParam.attr.oninput)
            jsParam.o.oninput();
    }

    function jsJogInertia() {
        if(!jsParam.ids['inertia']) return;
        requestAnimationFrame(jsJogInertia);

        jsParam.attr.diff /= 1.1
        if(Math.abs(jsParam.attr.diff) < 0.1) {
            jsParam.ids['inertia'] = 0;
            return;
        }

        jsParam.valueOrigin = Number(jsParam.valueOrigin) +
            Number(jsParam.attr.diff / (360 / 20)) * jsParam.attr.step;
        jsRangeCheck();

        const n = parseFloat(d.jog.style.transform.replace(/^[^\d-]+/g, '')) + jsParam.attr.diff;
        d.jog.style.transform = 'rotate(' + (360 + n) % 360 + 'deg)';
    }

    function jsJogshuttle(e) {
        jsParam.clickStart = false;

        const js = d.jogshuttle;
        if(js.style.display !== 'none' && jsParam.o === e) {
            jsParam.displayFlg ^= 1;
            if(!jsParam.displayFlg) {
                e.focus();
                js.style.display = 'none';
                jsRemoveHandler();
                jsParam.attr.diff = 0;
                return;
            }
        }

        jsParam.displayFlg = 1;
        jsParam.attr = {};
        const attr = e.attributes;
        for(let i = 0; i < attr.length; i++) {
            if(['min', 'max', 'step'].indexOf(attr[i].name) >= 0) {
                jsParam.attr[attr[i].name] = parseFloat(attr[i].value);
            }
        }

        const dataset = e.dataset.jogshuttle;
        if(dataset !== undefined) {
            const params = dataset.split(';');
            for(let l = 0; l < params.length; l++) {
                const param = params[l].split(':');
                if(param.length > 2) param[1] = param.slice(1).join(':');
                const key = param[0].trim().toLowerCase();
                let value = param[1] !== undefined ? param[1].trim() : '';
                if(['size',
                    'jog-ratio',
                    'jog-color',
                    'jog-background',
                    'shuttle-color',
                    'shuttle-background',
                    'oninput',
                    'onchange',
                    'target-id'].indexOf(key) >= 0) {
                    if(value.toLowerCase() === 'true') value = true;
                    else if(value.toLowerCase() === 'false') value = false;
                    else if(!isNaN(value)) value = Number(value);
                    jsParam.attr[key] = value;
                }
            }
        }

        jsParam.target =
            (jsParam.attr['target-id'] !== undefined && document.getElementById(jsParam.attr['target-id']) !== null) ?
            document.getElementById(jsParam.attr['target-id']) : e;

        jsSetStyle();

        if(jsParam.attr.step === undefined || !jsParam.attr.step) jsParam.attr.step = 1;
        jsParam.attr.stepDecDigit = String(parseFloat(jsParam.attr.step)).replace(/^[^.]+\.?/, '').length;

        if(jsParam.attr.step !== 1 && jsParam.attr.max) {
            const m = jsParam.attr.min !== undefined ? jsParam.attr.min : 0;
            jsParam.attr.max =
                Math.floor((jsParam.attr.max - m) / jsParam.attr.step) * jsParam.attr.step + m;
        }

        js.style.left = (e.offsetLeft) + 'px';
        js.style.top = (4 + e.offsetTop + e.clientHeight) + 'px';

        jsParam.o = e;
        jsParam.valueOrigin = isNaN(jsParam.target.value) ? 0 : Number(jsParam.target.value);
        jsParam.ids['inertia'] = 0;

        jsParam.k_ = 0;

        js.style.display = 'block';
        d.dummy.focus();

        jsRemoveHandler();
        if(!jsParam.touchFlg) jsAddHandler();
    }
}
~~~