Делаем вращательный регулятор.

    Этим топиком я продолжаю цикл статей о написании всяких вкусностей для MooTools. Сегодня мы на чистом JavaScript сделаем вращательный регулятор — контрол, который часто используют в работающих со звуком программах для регулировки громкости или баланса. Вот примерно такой:

    Sample

    Так как с помощью JavaScript нельзя вращать изображения, то мы поступим не так как поступили бы во флеше. Для реализации задачи нам понадобятся два изображения:

    Elements

    Идея в том, чтобы поместить индикатор на подложку и вращать его в соответствии с положением курсора мыши. Для вычисления положения индикатора нам пригодится математика. Не бойтесь, волновое уравнение решать нам не прийдется, вспомим немного школьной тригонометрии и полярную систему координат.

    Итак, полярная система координат определяет положение точки по двум величинам: угол поворота радиус-вектора относительно полярной оси φ и длина радиус-вектора r. Для лучшего понимания взгляните на схему ниже.

    Schema 1

    Жаль, что визуализировать точку в такой системе координат браузеру не под силу — он работает с декартовой системой. Но это не проблема, т.к. мы можем легко перейти из полярной системы в декартову используя формулы:

    x = r⋅cos(φ)
    y = r⋅sin(φ)

    Кооринаты x и y мы подставим в CSS-свойства left и top индикатора. Стоит отметить, что на схеме ось y направлена снизу вверх. В браузере же у нее противоположное направление. Это мы учтем, когда будем менять координаты индикатора.

    Ок, с этим разобрались. Но если r известна изначально (мы знаем размеры картинки), то как же определить угол φ из положения курсора мыши? Опять нам на помощь приходит школьная тригонометрия. Если взглянуть на следующую схему, то можно заметить, что вектор от полюса до точки положения курсора со своими проекциями образует прямоугольный треугольник.

    Schema 2

    Катетами в этом треугольнике будут координаты курсора. Отсюда мы можем найти тангенс угла φ как отношение противолежащего катета к прилежащему:

    tg(φ) = y / x

    Угол φ отсюда находим как арктангенс:

    φ = arctg(tg(φ)) = arctg(y / x)

    Из математики это, пожалуй, все. Теперь займемся кодингом. В HTML все предельно просто:
      <div id="Container">
        <div id="Indicator"></div>
      </div>

    CSS:
    #Container
    {
      position: relative;
      background-image: url('./images/rheostat.png');
      width: 64px;
      height: 64px;
    }

    #Indicator
    {
      position: absolute;
      background-image: url('./images/indicator.png');
      width: 4px;
      height: 4px;
      visibility: hidden;
    }


    JavaScript код написан с использованием фреймворка MooTools:
    var Rheostat = new Class({
      Implements: [Events, Options],
      
      // Задаем опци по умолчанию.
      options: {
        radius: 27,
        minValue: 0,
        maxValue: 100
      },
      
      // Константы для конвертирования градусов в радианы и наоборот.
      deg2rad: Math.PI / 180,
      rad2deg: 180 / Math.PI,
      
      // Флаг, указывающий на то, что мы зажали левую кнопку мыши.
      captured: false,
      
      // Коструктор
      initialize: function(container, indicator, options){
        this.setOptions(options);
        this.indicator = $(indicator);
        this.container = $(container);
        
        // Показываем спрятанный по умолчанию индикатор.
        this.indicator.fade('show');
        
        // Добавляем обработку событий мыши.
        this.container.addEvent('mousedown', this.captureMouse.bind(this));
        document.addEvents({
          'mousemove': this.updateAngle.bind(this),
          'mouseup': this.releaseMouse.bind(this)
        });

        // Размер контейнера нам нужен для того, чтобы сместить систему координат индикатора
        // из левого верхнего угла контейнера в его центр.
        var containerSize = this.container.getSize();
        var indicatorSize = this.indicator.getSize();
        this.offset = {
          x: Math.floor(containerSize.x / 2) - Math.floor(indicatorSize.x / 2),
          y: Math.floor(containerSize.y / 2) - Math.floor(indicatorSize.y / 2)
        };
        
        this.angle = 0;
        this.updateIndicatorPosition();
      },
      
      // Запоминаем, что контрол захвачен мышью.
      captureMouse: function(){
        this.captured = true;
      },
      
      // Стираем флаг захвата.
      releaseMouse: function(){
        this.captured = false;
      },
      
      // В этом методе считается угол по положению курсора мыши.
      updateAngle: function(e){
        if (this.captured)
        {
          var containerPosition = this.container.getPosition();
          
          // Катеты нашего треугольника.
          // К mouseLeft я прибавил 0.1 для того, чтобы избежать возможного деления на ноль впоследствии.
          var mouseLeft = e.client.x - this.offset.x - containerPosition.x + 0.1;
          var mouseTop = this.offset.y - e.client.y + containerPosition.y;
          
          // Вычисление угла (т.к. Math.atan() возвращает значение в радианах,
          // для более простого оперирования с ним переведем его в градусы).
          this.angle = Math.atan(mouseTop / mouseLeft) * this.rad2deg;
          
          // Т.к. функция арктангенса может вернуть нам значения только от -90 до +90, то
          // если курсор находится в левой половине к углу прибавим 180. Иначе в левой половине
          // мы индикатор никогда не увидим.
          if (mouseLeft < 0)
            this.angle += 180;
          
          // Еще одна проверка, чтобы сплошную последовательность значений от 0 до 360 градусов.
          if (this.angle < 0)
            this.angle += 360;
          
          // Считаем значение в соответствии с заданными минимальным и максимальным значениями регулируемой величины.
          var value = Math.floor((this.options.maxValue - this.options.minValue) * this.angle / 360 + this.options.minValue);
          this.fireEvent('valueChanged', value)
          this.updateIndicatorPosition();
        }
      },
      
      updateIndicatorPosition: function(){
        // Переводим угол в радианы для передачи его в Math.cos() и Math.sin().
        var radAngle = this.angle * this.deg2rad
        var left = this.options.radius * Math.cos(radAngle) + this.offset.x;
        
        // Обратите внимание на знак "-". Этим мы разворачиваем ось y наоборот.
        var top = -this.options.radius * Math.sin(radAngle) + this.offset.y;
        
        // Позиционирование индикатора.
        this.indicator.setStyle('left', left);
        this.indicator.setStyle('top', top);
      }
    });

    Конструируется экземпляр этого регулятора так:
    var rheostat = new Rheostat('Container', 'Indicator');

    Используем событие изменения значения:
    rheostat.addEvent('valueChanged', function(value){
      // В value попадет текущее значение регулируемого параметра.
    });

    Вот вроде пока и все. Позже я расскажу, как использовать для регулировки колесико мыши и как ограничить вращение индикатора с обоих сторон. Пожелания на дополнительную функциональность принимаются.

    UPD. Улучшенный скрипт, устраняющий многие недостатки:
    var Rheostat = new Class({
      Implements: [Events, Options],
      
      // Задаем опци по умолчанию.
      options: {
        radius: 27,
        
        // Диапазон величин регулируемого параметра.
        minValue: 0,
        maxValue: 100,
        
        // Ограничение углов вращения индикатора.
        minAngle: 50,
        maxAngle: 310,
        
        // Сдвиг нуля шкалы в градусах.
        angleOffset: -90,
        
        // Развернуть ли шкалу?
        reversed: true
      },
      
      // Константы для конвертирования градусов в радианы и наоборот.
      deg2rad: Math.PI / 180,
      rad2deg: 180 / Math.PI,
      
      // Флаг, указывающий на то, что мы зажали левую кнопку мыши на контроле.
      captured: false,
      
      angle: 0,
      mouseAngle: 0,
      oldMouseAngle: 0,
      
      // Конструктор.
      initialize: function(container, indicator, options){
        this.setOptions(options);
        this.indicator = $(indicator);
        this.container = $(container);
        
        // Добавляем обработку событий мыши.
        this.container.addEvents({
          'mousedown': this.captureMouse.bind(this),
          'mousewheel': this.handleWheel.bind(this),
        });
        
        document.addEvents({
          'mousemove': this.updateAngle.bind(this),
          'mouseup': this.releaseMouse.bind(this)
        });

        // Размер контейнера нам нужен для того, чтобы сместить систему координат индикатора
        // из левого верхнего угла контейнера в его центр.
        var containerSize = this.container.getSize();
        var indicatorSize = this.indicator.getSize();
        this.offset = {
          x: Math.floor(containerSize.x / 2) - Math.floor(indicatorSize.x / 2),
          y: Math.floor(containerSize.y / 2) - Math.floor(indicatorSize.y / 2)
        };
        
        // Угол по умолчанию в начало шкалы.
        this.angle = this.options.minAngle + this.options.angleOffset;
        this.updateIndicatorPosition();
        
        // Показываем спрятанный по умолчанию индикатор.
        this.indicator.fade('hide').fade('in');
      },
      
      // Обработка колесика мыши.
      handleWheel: function(e){
        // Вычисление угла.
        var wheelAngle = this.angle + e.wheel;
        if ((wheelAngle >= this.options.minAngle) && (wheelAngle <= this.options.maxAngle)){
          this.oldMouseAngle = this.mouseAngle = this.angle = wheelAngle;
          this.updateIndicatorPosition();
        }
      },
      
      // Запоминаем, что контрол захвачен мышью.
      captureMouse: function(e){
        this.captured = true;
        
        // Выставляем индикатор в место клика.
        var mouseAngle = this.getMouseAngle(e);
        if ((mouseAngle >= this.options.minAngle) && (mouseAngle <= this.options.maxAngle)){
          this.oldMouseAngle = this.mouseAngle = this.angle = mouseAngle;
          this.updateIndicatorPosition();
        }
      },
      
      // Стираем флаг захвата.
      releaseMouse: function(){
        this.captured = false;
      },
      
      // В этом методе считается угол по положению курсора мыши.
      getMouseAngle: function(e){
        var containerPosition = this.container.getPosition();
      
        // Катеты нашего треугольника.
        // К mouseLeft я прибавил 0.1 для того, чтобы избежать возможного деления на ноль впоследствии.
        var mouseLeft = e.client.x - this.offset.x - containerPosition.x + 0.1;
        var mouseTop = this.offset.y - e.client.y + containerPosition.y;
      
        // Вычисление угла наклона курсора (т.к. Math.atan() возвращает значение в радианах,
        // для более простого оперирования с ним переведем его в градусы).
        var angle = Math.atan(mouseTop / mouseLeft) * this.rad2deg;
      
        // Т.к. функция арктангенса может вернуть нам значения только от -90 до +90, то
        // если курсор находится в левой половине к углу прибавим 180. Иначе в левой половине
        // мы индикатор никогда не увидим.
        if (mouseLeft < 0)
          angle += 180;
      
        // Еще одна проверка, чтобы иметь сплошную последовательность значений от 0 до 360 градусов.
        if (angle < 0)
          angle += 360;
      
        return angle - this.options.angleOffset;
      },
      
      // Вычисляем угол поворота индикатора на основе направления движения курсора мыши.
      updateAngle: function(e){
        // Захвачен ли контрол мышью?
        if (this.captured){
          var mouseAngle = this.getMouseAngle(e);
      
          // Приращение угла индикатора.
          var diffAngle = mouseAngle - this.oldMouseAngle;
          
          // Проверка пересачения границ.
          if ((this.angle + diffAngle >= this.options.minAngle) && (this.angle + diffAngle <= this.options.maxAngle))
            this.angle += diffAngle;

          this.oldMouseAngle = this.mouseAngle = mouseAngle;
          this.updateIndicatorPosition();
        }
      },
      
      // Считаем значение в соответствии с заданными минимальным и максимальным значениями регулируемой величины.
      updateValue: function(){
        var value = Math.floor(
          (this.options.maxValue - this.options.minValue + 1) *
          (this.angle - this.options.minAngle) /
          (this.options.maxAngle - this.options.minAngle)
        );
      
        // Генерируем событие об изменившемся значении.
        this.fireEvent('valueChanged', (this.options.reversed) ? this.options.maxValue - value : value);
      },
      
      // Обновление положения индикатора.
      updateIndicatorPosition: function(){
        // Переводим угол в радианы для передачи его в Math.cos() и Math.sin().
        var radAngle = (this.angle + this.options.angleOffset) * this.deg2rad;
        var left = this.options.radius * Math.cos(radAngle) + this.offset.x;
        
        // Обратите внимание на знак "-". Этим мы разворачиваем ось y наоборот.
        var top = -this.options.radius * Math.sin(radAngle) + this.offset.y;
        
        // Позиционирование индикатора.
        this.indicator.setStyles({left: left, top: top});
        
        // Обновляем значение.
        this.updateValue();
      }
    });

    Пример с регулировкой размера шрифта.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 72

      0
      отличный плагин, вот только в опере 9.22 не пашет:-) а в мозилке 3+ пашет… это радует
        –2
        И в Safari тоже.
          0
          тоже что? работает или нет? )
          Уточните плз, а то нет возможности (и желания) проверить.
            +3
            У меня в Safari на маке работает.
              +1
              IE 7 (7.0.5730.13) — не работает. Строка 2705, символ 3: Объект не поддерживает этот метод или свойство.
              Firefox 3.0.3 — работает. Opera 9.6 — работает. Safari 3.1.2 — работает. Google Chrome 0.2.149.30 — не работает. Всё на PC.
                0
                Исправил скрипт, теперь работает и в IE 7. Возможно, заработал и в других браузерах. Проверить не могу.
                  0
                  Да, работает.
                  Хром не в счет.
                    +1
                    Странно, у меня в Хромом 0.2.149.30 работает.
                0
                в Сафари на Маке работает, но вид курсора как для ввода текста становится, не очень подходит…
                ну и логически уменьшение-увеличение шрифта нужно поменять — по привычке увеличение идет по часовой стрелке ))
              0
              Opera 9.6. Все отлично. Спасибо!
              +3
              Тут на Mootools аналогичным способом (крутя стрелки) выбирается время: www.nogray.com/time_picker.php
                0
                Гм, совсем не в тему но: вы случайно не знаете где можно скачать это все сезоны этого шоу? Или хотябы какие то сезоны… у меня есть кое что, но там явно не все(всего лишь 29 серий и не очень понятно из каких они сезонов). Была бы идеальна прямая ссылка=)
                  +4
                  Чето я куда-то не туда это написал… извиняюсь. Хотя и здесь это тоже «совсем не в тему» =)
                    0
                    :)))))
                • НЛО прилетело и опубликовало эту надпись здесь
                  +4
                  Все хорошо, но его поведение «не адекватное», он не должен кидать значение из максимального в минимальное и обратно при прохождении нулевой позиции. Вы где-то видели такой регулятор громкости? Вот и я нет…
                    +2
                    Вот Вам и домашнее задание :-)
                      0
                      может автор использовал плагин ссылку на который указал Vorchun… там часы тоже прокручиваются
                      задумка с реостатом хорошая, но надо еще доработать
                        0
                        Я не говорю что они должны прокручиваться, просто не должно быть «скачков» с минимума на максимум.
                          0
                          *Я не говорю что они не должны прокручиваться
                        0
                        Я в самом конце поста признался, что на этом работа не окончена и доработки еще будут. И этот момент также будет дорабатываться.
                          0
                          а вы с нуля делали или использовали какие-то чужие наработки?
                            0
                            С нуля. Вот почему-то взбрело в голову такое сделать. Просто раньше на JavaScript такого не видел. А Vorchun, как оказывается, видел =)
                              +1
                              ловите плюс в карму:-)
                          0
                          а мне кажется, что эта самая нулевая позиция должна быть внизу, в крайнем случае вверху, но никак не справа
                            0
                            Да, я тоже уже так подумал…
                          0
                          можно на регулятор поставить ещё «cursor:pointer»
                            +1
                            А если вы еще сделаете что бы он на скролл реагировал, вообще супер будет
                              +1
                              В смысле колесико мыши? Да, я собираюсь это делать.
                              0
                              думаю, было бы только плюсом, если по нажатии мышки на поле этого реостата, индикатор бы автоматом туда переезжал…
                              сейчас если только мышкой проведешь, то он переедет
                                0
                                Хорошее пожелание. Будет.
                                  0
                                  Уже переезжает. Скорее всего еще будет анимация.
                                +2
                                В музыкальных программах часто делают не так. В Guitar Rig, к примеру, после нажатия мышкой на регулятор, движение вниз уменьшает, а движение вверх — увеличивает значение. Так точнее получается имхо.
                                  +1
                                  В плане точности — Вы можете отвести курсор при нажатой кнопке сколь угодно далеко и регулировать точнее.
                                    0
                                    Это зависит от того, как близко к краю экрана регулятор воткнут.

                                    А мне вот у 3D Studio MAx нравилось — когда у числовых полей стрелки вверх-вниз разделялись строчкой, которую мышью можно было таскать вверх-вниз по экрану, меняя значение на больше-меньшее — так больше всего мне нравилось, что мышь в край экрана не упиралась, а сама перескакивала на другую сторону экрана и позволяла тянуть значение дальше!

                                    Так вот и у таких крутилок — по кругу вокруг них мышь водить это конечно интуитивно, но вот вариант прямолинейного движения с максимальной точностью, но не ограниченный экраном бывает удобнее.

                                    Хотя, для кругового движения по большому радиусу тоже можно попробовать экран замкнуть :) Не глядя на экран двигать будет удобно, а глядя после пары перескоков можно потерять направление. Хотя — это тоже вполне решается рисованием радиус-вектора от регулятора до мыши? с учётом всех перескоков… Но это уже наверно не Java-script
                                • НЛО прилетело и опубликовало эту надпись здесь
                                    +2
                                    тупой троль
                                    • НЛО прилетело и опубликовало эту надпись здесь
                                    +2
                                    Всегда интересовал вопрос. Зачем нужен такой контрол в компьютерных интерфейсах? Не считая эстетического эффекта конечно.
                                      +1
                                      Людям, которые работают с «железными» такими регуляторами скорее всего проще понимать таковые программные.
                                      0
                                      в IE 7 не работает
                                        0
                                        Уже должен работать.
                                          0
                                          ЦУП подтверждает: полет в ИЕ7 нормальный
                                        0
                                        Автор, спасибо за топик. На основе этого кода, те кому надо смогут понять как оно впринципе работает и применять там, где им нужно.
                                          0
                                          Я этого и пытался добиться ))
                                            0
                                            и за это вам большое спасибо!
                                        • НЛО прилетело и опубликовало эту надпись здесь
                                            0
                                            Я думал заменить абсолютное изменение угла на приращения. Ввести такую себе величину dφ и смотреть, в какую сторону мы крутим регулятор по знаку этой величины. Как только крутя в одну сторону мы заходим за заданную границу, скрипт это понимает и отказывается двигать регулятор дальше. Это пока только наброски в уме. На выходных допишу.
                                            0
                                            Зачет
                                              +1
                                              Скрипт хорош.
                                              Из возможных улучшений.
                                              — еще не плохо, при вращении, отменить выделение текста.
                                              — возможность поставить фокус и управлять стреклами

                                              Но и без этого пригодиться
                                                0
                                                На выходных займусь дописыванием.
                                                +6
                                                Так вот зачем нужна математика! :)
                                                  0
                                                  Firefox 3.1a2 — работает, Opera 9.26 — работает, Safari 3.1.2 — работает
                                                  всё на маке
                                                    +3
                                                    Скромно замечу, что вращательный регулятор на экране — очень неудобная штука. Потому что рука на мышке лежит так, что мышкой удобнее возить влево-вправо, а не по кругу. На физическом же устройстве крутить горизонтальный ползунок менее удобно, чем круглую ручку из-за того, что необходимо упереть кисть во что-нибудь для повышения точности настройки. Круглую ручку можно крутить двумя пальцами в то время, как кисть может висеть в воздухе.
                                                    Каждый элемент интерфейса выглядит так, как выглядит не потому, что у дизайнера в жопе зачесалось, а потому что так удобнее решать поставленную задачу в конкретных условиях.

                                                    Это я все к тому, что я не люблю неудобные глючные вертелки, а люблю удобные и неглючные. Не делайте так, как описано в этой статье.
                                                      0
                                                      А еще круглая ручка занимает меньше места.
                                                        0
                                                        я думаю что если такой регулятор крутить колесом мыши, то это удобно

                                                        с другой стороны почему тогда на микшерских пультах основная настройка, а именно громкость всегда выполняется в виде ползунка?

                                                        можно сказать конечно, что так более наглядно?
                                                        я бы сказал что если можно упереть руку то ползунок нагляден и удобен одновременно
                                                          +1
                                                          Колесом мыши крутить, конечно, можно. Только оно крутится не в той плоскости, что ручка на экране, так что вариант не катит.
                                                          А вообще, единственный способ проверить удобство — сделать несколько вариантов и поиграться с ними на реальном макете. Всё остальное — домыслы. Неудобство любых круглых ручек в компьютерных интерфейсах я уже успел проверить на себе. Потому и предостерегаю модных джаваскриптеров от дурацкого времяпрепровождения.

                                                            0
                                                            Просто пачка таких движков на пульте микшера существенно нагляднее — оно ж столбцовая диаграмма сам по себе. И там есть куда пальцам рядом с движком опереться, оно ж обычно более горизонтальное, так что помогая в одном, в другом не мешает.
                                                            0
                                                            Поддерживаю. Довелось мне тут недавно видеть такое решение в виде скина к аудиоплееру — неудобно! Так что не тратьте время зря.
                                                            +1
                                                            я бы крутилку вешал не на 2 дива а на инпут. ну так, на всякий пожарный )
                                                              0
                                                              А можно поподробнее? Это для того, чтобы табом можно было переходить?
                                                                –1
                                                                в принципе и это тоже.
                                                                а вообще, если через крутилку предполагается ввод какого-то параметра, то он должен в идеале работать хоть как-то и без поддержки яваскрипта.
                                                                ну и так как-то семантичнее что-ли :)
                                                              0
                                                              за такой пост можно и по карме плюсиком схлопотать… биг сенкс
                                                                0
                                                                А ещё можно сделать валкодер, как в радиопередатчике. Позволит быстро, удобно и точно подстроить частоту. Даже слишком большой динамический диапазон получается. Причём, чем больше разрешение экрана, тем больше точность…
                                                                  0
                                                                  Было бы замечательно, если бы узел тени был острее.
                                                                  А не сделать бы Вам выбор цвета при помощи трех таких регуляторов?
                                                                    0
                                                                    Думаю привычнее будет чтобы при вращение по часовой стрелке шрифт увеличивался, а не наоборот.
                                                                      0
                                                                      Я еще введу параметр, который позволит развернуть шкалу в любом направлении.
                                                                      0
                                                                      Как уже говорили:
                                                                      Точку мин/макс вниз или вверх (отметить его как-то графически что-ли)
                                                                      Убрать скачек в это точке (довольно легко делается на самом деле)
                                                                      Курсор хорошо бы сделать «руку» при наведении и вращании
                                                                      При зажатом Ctrl (Alt) сделать изменение по движению влево/вправо (вверх/вниз), для тех кому удобнее.
                                                                      Изменение значения по колесику мышки (если скролла на странице нет естественно)
                                                                      Про стрелки — спорно, лучше поставить рядом инпут и дать ввести число в этом случае.

                                                                      А вращение по эллипсу было бы еще круче, ни к чему, но круто ;)
                                                                        0
                                                                        в центре лучше сделать мертвую зону (круглую) иначе слишком большой диапазон приходится на слишком маленькую окружность и значение меняется резко и непредсказуемо
                                                                          0
                                                                          Вот это: var mouseTop = this.offset.y — e.client.y + containerPosition.y;
                                                                          Следует заменить на: var mouseTop = this.offset.y — e.page.y + containerPosition.y;

                                                                          А то скроллинг страницы не учитывался и крутилка вела себя неадекватно.
                                                                            0
                                                                            Спасибо, учтем.
                                                                            0
                                                                            Не работает демка.

                                                                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                                            Самое читаемое