Help us understand the problem. What is going on with this article?

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

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

image.jpg

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

設定方法

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

基本設定

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

動作サンプル

設定項目

inputタグ自体の属性

適用させるinputタグにminmaxstep属性があれば、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()を発火させます。
対象要素にonchangeoninputが設定されていてもその要素のvalueをJavaScriptで変更しただけでは発火しないため、元要素に対してonchange()oninput()メソッドを実行することで発火させています。
target-id 別要素のidを指定することで、値を変更する対象の要素を変更することができます。

各設定動作サンプル

スクリプト

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 = 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';

        // 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 = 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 = 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').innerHTML +=
            "<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;
            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();
    }
}
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away