Для кого и зачем эта статья?


Когда изучаешь новую технологию или язык программирования, основные понятия всегда носят относительно рутинный характер и поэтому, на мой взгляд, быстро отбивают желание обучаться у начинающих. Цель данной статьи — это заинтересовать и увлечь читателя изучением программирования на примере разработки элементарной графики в динамическом режиме. Статья подойдет для начинающих разработчиков, которые ознакомились с основами HTML5 и JavaScript, и которым наскучило видеть статический текст на страничке при выводе в консоль браузера массивов, объектов, результатов арифметических операций и т.д. Далее мы реализуем простейшую, но полезную для понимания языка анимацию.

Что мы будем делать?


Рассмотрим процесс создание простейших аналоговых часов средствами HTML5 и JavaScript. Рисовать часы будем графическими примитивами, не используя средств CSS. Мы вспомним немного геометрии для отображения нашей графики, вспомним немного математики для реализации логики отображения наших анимированных часов. И в целом постараемся уменьшить энтропию в познаниях языка JavaScript. Для разработки нам понадобится текстовый редактор вроде Notepad++ или Sublime Text 3.

Реализация цифровых часов


Создадим три файла в текстовом редакторе. (Все три файла должны лежать в одной папке).

index.html — основная страничка
clockscript.js — скрипт с логикой работы
style.css — файл стилей

Для начала выведем текущее время в обычный div-блок в .html файл. Даже в такой маленькой задаче есть свой подводный камень. Если просто закинуть функцию отображения часов в событие onload у тега body, то текущее время отобразится в строке, но так и останется статическим. И div-блок, в который мы отправили строку с текущим временем, не будет самостоятельно обновляться.

Добиться самостоятельного обновления элемента страницы можно оборачиванием функции отображения времени в анонимный метод, который присваивается свойству onload корневого объекта Window.

Один из вариантов реализации может быть следующим. Файл index.html:

<!DOCTYPE html>
<html>
  <head>
	<title>Часы</title>
	<meta http-equiv = "Content-Type" content = "text/html; charset = utf-8" >
	<link rel = "stylesheet" type = "text/css" href = "style.css">
	<script src = "clockscript.js"></script>
  </head>	
  <body>
     Черновик по JavaScript. Работа с холстом: <br>
	 <div id='clock'>Тут будет текущее время</div> <br>
	 <canvas height='480' width='480' id='myCanvas'></canvas>
  </body>
</html>

Файл style.css:

#clock{
  font-family:Tahoma, sans-serif;
  font-size:20px;
  font-weight:bold;
  color:#0000cc;	
}

Файл clockscript.js:

window.onload = function(){
   window.setInterval(
	function(){
	    var d = new Date();
	    document.getElementById("clock").innerHTML = d.toLocaleTimeString();
	}
  , 1000);
}

Разберемся с работой clockscript.js:

Выполняем внутренний JavaScript-код при помощи привязки к событию onload корневого объекта Window:

window.onload = function(){/*бла-бла-бла*/}

Метод объекта объекта Window, который выполняет код через определенные промежутки времени (указанные в миллисекундах):

window.setInterval(function(){/*Тут действия, обернутые в функцию, которую нужно выполнять каждые 1000 миллисекунд*/} , 1000);

Объект Date используется для проведения различных манипуляций с датой и временем. С помощью конструктора создаем его экземпляр и называем d:

var d = new Date();

Находим объект DOM по его id. Это именно тот объект, в который мы хотим выводить наше время. Это может быть параграф, заголовок или еще какой-то элемент. У меня это div-блок. После получения элемента по id, используем его свойство innerHTML для получение всего содержимого элемента вместе с разметкой внутри. И передаем туда результат метода toLocaleTimeString(), который возвращает форматированное представление времени:

document.getElementById("clock").innerHTML = d.toLocaleTimeString();

Вот, что должно получиться(время динамически изменяется каждую секунду):



Реализация аналоговых часов


С этого момента мы будем использовать Canvas (HTML), который будет служить нам холстом для творчества.

Чтобы увидеть наш холст в файле index.html внутри body мы должны где-то расположить следующий тег, сразу определив его размеры:

<canvas height='480' width='480' id='myCanvas'></canvas>

Теперь в файле clockscript.js, прежде чем пытаться рисовать, нужно получить контекст объекта Canvas. Сделаем это в начале нашей функции отображения часов. Тогда файл clockscript.js изменится следующим образом:

function displayCanvas(){
    var canvasHTML = document.getElementById('myCanvas');
    var contextHTML = canvasHTML.getContext('2d');
    contextHTML.strokeRect(0,0,canvasHTML.width, canvasHTML.height);
    //Тут будет вся логика часов и код отображения через графические примитивы
    return;
}

window.onload = function(){
   window.setInterval(
	function(){
	    var d = new Date();
	    document.getElementById("clock").innerHTML = d.toLocaleTimeString();
            displayCanvas();
	}
  , 1000);
}

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

Угол поворота всех стрелок за 1 секунду:

  • Секундная стрелка повернется на угол — (1/60)*360o = 6o
  • Минутная стрелка повернется на угол — (1/60)*6o = 0,1o
  • Часовая стрелка повернется на угол — (1/60)*0,1o ? 0,0017o

Первая проблема:

То есть даже за 1 секунду все стрелки должны повернуться, каждая на соответствующий угол. И если это не учесть, то первый подводный камень, который мы получим в отображении, будет некрасивая анимация. К примеру, когда время будет 19:30, то часовая стрелка будет ровно показывать на 19 часов, хотя в реальной жизни она должна уже быть наполовину приближена к 20 часам. Аналогично, приятнее будет выглядеть плавное передвижение минутной стрелки. Ну а секундная стрелка пусть перещелкивается дискретными движениями, как в большинстве реальных механических часов. Решение проблемы: прибавлять к углы поворота текущей стрелки угол поворота более быстрой стрелки, домноженный на коэффициент, обозначающий его долю от угла текущей стрелки.

Реализация:

var t_sec = 6*d.getSeconds();  //Определяем угол для секунд
var t_min = 6*(d.getMinutes() + (1/60)*d.getSeconds()); //Определяем угол для минут
var t_hour = 30*(d.getHours() + (1/60)*d.getMinutes());  //Определяем угол для часов

Вторая проблема:

Угол вращающегося радиус-вектора(стрелки часов) отсчитывается от положительного направления в направлении против часовой стрелки. Если мы это не учтем в нашей логике, то направим часы назад в прошлое.

И еще, отсчет часов, минут и секунд у нас происходит от цифры 12, верхнего положения. Решение проблемы: в наших формулах мы должны учесть это в качестве сдвига +?/2 (90o). А перед значением угла ставить знак "-", чтобы часы шли именно по часовой стрелке. И, конечно, учитывать, что передача угла в градусах в тригонометрические функции языков программирования осуществляется с умножением на коэффициент "?/180o".

Реализация на примере секундной стрелки:

contextHTML.moveTo(xCenterClock, yCenterClock);
contextHTML.lineTo(xCenterClock + lengthSeconds*Math.cos(Math.PI/2 - t_sec*(Math.PI/180)), 
                    yCenterClock - lengthSeconds*Math.sin(Math.PI/2 - t_sec*(Math.PI/180)));

Третья проблема:

В ходе разметки рисочек циферблата нужно как-то выделить рисочки напротив часов. Всего рисочек — 60 для секунд и минут. 12 — для часов. Эти 12 должны как-то выделяться на фоне всех остальных. Также симметричность оцифровки зависит от ширины цифр. Очевидно, что цифры 10, 11 и 12 шире, чем 1, 2, 3 и т.д. Про это нужно не забыть.

Решение проблемы и вариант оцифровки циферблата:

for(var th = 1; th <= 12; th++){
	contextHTML.beginPath();
	contextHTML.font = 'bold 25px sans-serif';
	var xText = xCenterClock + (radiusNum - 30) * Math.cos(-30*th*(Math.PI/180) + Math.PI/2);
	var yText = yCenterClock - (radiusNum - 30) * Math.sin(-30*th*(Math.PI/180) + Math.PI/2);
	//Следующий кусок когда учитывает то, что симметричность оцифровки зависит от ширины цифры
	//Начиная с "10" часов это нужно корректировать(!)
	if(th <= 9){
	    contextHTML.strokeText(th, xText - 5 , yText + 10);
	}else{
	    contextHTML.strokeText(th, xText - 15 , yText + 10);
	}
     	contextHTML.stroke();
	contextHTML.closePath();	
}

С учетом всего этого, мой вариант исполнения кода логики и отображения аналоговых часов выглядит следующим образом:

Код clockscript.js
function displayCanvas(){
    var canvasHTML = document.getElementById('myCanvas');
    var contextHTML = canvasHTML.getContext('2d');
    contextHTML.strokeRect(0,0,canvasHTML.width, canvasHTML.height);
	
    //Расчет координат центра и радиуса часов
    var radiusClock = canvasHTML.width/2 - 10;
    var xCenterClock = canvasHTML.width/2;
    var yCenterClock = canvasHTML.height/2;
	
    //Очистка экрана. 
    contextHTML.fillStyle = "#ffffff";
    contextHTML.fillRect(0,0,canvasHTML.width,canvasHTML.height);
	
    //Рисуем контур часов
    contextHTML.strokeStyle =  "#000000";
    contextHTML.lineWidth = 1;
    contextHTML.beginPath();
    contextHTML.arc(xCenterClock, yCenterClock, radiusClock, 0, 2*Math.PI, true);
    contextHTML.moveTo(xCenterClock, yCenterClock);
    contextHTML.stroke();
    contextHTML.closePath();
	
    //Рисуем рисочки часов
    var radiusNum = radiusClock - 10; //Радиус расположения рисочек	
    var radiusPoint;
    for(var tm = 0; tm < 60; tm++){
	  contextHTML.beginPath();
	  if(tm % 5 == 0){radiusPoint = 5;}else{radiusPoint = 2;} //для выделения часовых рисочек
	  var xPointM = xCenterClock + radiusNum * Math.cos(-6*tm*(Math.PI/180) + Math.PI/2);
	  var yPointM = yCenterClock - radiusNum * Math.sin(-6*tm*(Math.PI/180) + Math.PI/2);
	  contextHTML.arc(xPointM, yPointM, radiusPoint, 0, 2*Math.PI, true);
	  contextHTML.stroke();
	  contextHTML.closePath();
    } 
	
    //Оцифровка циферблата часов
    for(var th = 1; th <= 12; th++){
	contextHTML.beginPath();
	contextHTML.font = 'bold 25px sans-serif';
	var xText = xCenterClock + (radiusNum - 30) * Math.cos(-30*th*(Math.PI/180) + Math.PI/2);
	var yText = yCenterClock - (radiusNum - 30) * Math.sin(-30*th*(Math.PI/180) + Math.PI/2);
	if(th <= 9){
		contextHTML.strokeText(th, xText - 5 , yText + 10);
	}else{
		contextHTML.strokeText(th, xText - 15 , yText + 10);
	}
     	contextHTML.stroke();
	contextHTML.closePath();	
    }

	
    //Рисуем стрелки
    var lengthSeconds = radiusNum - 10;
    var lengthMinutes = radiusNum - 15;
    var lengthHour = lengthMinutes / 1.5;
    var d = new Date();                //Получаем экземпляр даты
    var t_sec = 6*d.getSeconds();                           //Определяем угол для секунд
    var t_min = 6*(d.getMinutes() + (1/60)*d.getSeconds()); //Определяем угол для минут
    var t_hour = 30*(d.getHours() + (1/60)*d.getMinutes()); //Определяем угол для часов
	
    //Рисуем секунды
    contextHTML.beginPath();
    contextHTML.strokeStyle =  "#FF0000";
    contextHTML.moveTo(xCenterClock, yCenterClock);
    contextHTML.lineTo(xCenterClock + lengthSeconds*Math.cos(Math.PI/2 - t_sec*(Math.PI/180)),
				yCenterClock - lengthSeconds*Math.sin(Math.PI/2 - t_sec*(Math.PI/180)));
    contextHTML.stroke();
    contextHTML.closePath();

    //Рисуем минуты
    contextHTML.beginPath();
    contextHTML.strokeStyle =  "#000000";
    contextHTML.lineWidth = 3;
    contextHTML.moveTo(xCenterClock, yCenterClock);
    contextHTML.lineTo(xCenterClock + lengthMinutes*Math.cos(Math.PI/2 - t_min*(Math.PI/180)),
				 yCenterClock - lengthMinutes*Math.sin(Math.PI/2 - t_min*(Math.PI/180)));
    contextHTML.stroke();
    contextHTML.closePath();

    //Рисуем часы
    contextHTML.beginPath();
    contextHTML.lineWidth = 5;
    contextHTML.moveTo(xCenterClock, yCenterClock);
    contextHTML.lineTo(xCenterClock + lengthHour*Math.cos(Math.PI/2 - t_hour*(Math.PI/180)),
				 yCenterClock - lengthHour*Math.sin(Math.PI/2 - t_hour*(Math.PI/180)));
    contextHTML.stroke();
    contextHTML.closePath();	
	
    //Рисуем центр часов
    contextHTML.beginPath();
    contextHTML.strokeStyle =  "#000000";
    contextHTML.fillStyle = "#ffffff";
    contextHTML.lineWidth = 3;
    contextHTML.arc(xCenterClock, yCenterClock, 5, 0, 2*Math.PI, true);
    contextHTML.stroke();
    contextHTML.fill();
    contextHTML.closePath();
	  
    return;
}


window.onload = function(){
    window.setInterval(
	function(){
		var d = new Date();
		document.getElementById("clock").innerHTML = d.toLocaleTimeString();
		displayCanvas();
	}
    , 1000);
}


Результат работы js-скрипта на JSFiddle

Заключение


Вот такие часы должны получиться в итоге. Надеюсь, что данная статья поможет вам разобраться в элементарной анимации на JavaScript и зародит у вас интерес к освоению этого прекрасного языка программирования. Спасибо за внимание.

Комментарии (21)


  1. AndreyNagih
    18.01.2016 14:11
    +2

    Спасибо за пост. Есть некоторые замечания.

    setInterval — это очень плохая идея для продакшена. Лучше — вложенные setTimeout.
    Но с часами отдельная история: если хочется попадать в секунды, то нужно каждый раз подстраивать время таймаута, т.к. из-за внутреннего устройства JS (EventLoop), таймер выполняется не точно в назначенное время, а где-то около него.

    И еще, очень хочется демку, чтобы сразу поиграть с ней.


    1. Impressive_i
      18.01.2016 14:49
      +1

      Спасибо Вам за замечания.
      Вы имеете ввиду то, что метод setInterval относительно медленный и из-за него часы могут сбиваться с точного времени?
      Просто я помню, что setTimeout выполняет код один раз и, честно говоря, не знал, что вложенные setTimeout будут в целом работать быстрее одного setInterval.
      Насчет демки, вот полный код: yadi.sk/d/YveCwtS5nFaFN
      Если Вы сможете прокомментировать/исправить проблемные моменты в коде, то буду рад, если поделитесь им мне на почту im_prezz@mail.ru


      1. Zibx
        18.01.2016 14:57

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

        setTimeout(функция, 1000 - (new Date()).getMilliseconds())
        

        А в функции ставить новый таймаут и звать реквестАнимэйшенФрэйм. И уже в нём рисовать.


      1. AndreyNagih
        18.01.2016 14:59

        Дело не в скорости, а в том как работает EventLoop.
        setInterval ставит задачу в него безусловно, отработала текущая итерация или нет.
        В результате, если выполняемая задача начинает есть больше времени чем период интервала (по разным причинам), то задачи в очереди начнут накапливаться. В результате браузеру будет некогда заниматься другими задачами, он будет тратить все свое время на обслуживание этого setInterval.

        Обойти эту проблему помогает использование setTimeout, который планирует лишь одну, следующую, итерацию вычислений. В конце который вы снова вызываете setTimeout. Таким образом задача сама себя ставит в очередь. И в итоге, если она начинает тормозить, то и планировать себя она начинает медленнее, т.е. тормозит только эта подвисшая задача.

        При использовании setInterval браузер может «зафризиться», при рекурсивном setTimeout — добиться этого гораздо сложнее.

        И еще раз заострю внимание: время таймаута для следующей итерации нужно вычислять каждый раз через объект Date, т.к. JS вам не гарантирует точность тайминга асинхронных вызовов. Т.е. на одной итерации у вас будет 1000ms, на другой, может 950ms, а на третей может быть 1050ms.

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


  1. k12th
    18.01.2016 14:15
    +5

    Ну зачем же каждую секунду делать document.getElementById("clock"), document.getElementById('myCanvas') и canvasHTML.getContext('2d')? А потом начинается «ой, HTML5 это очень непроизводительно» и «от снежинок на сайте вентилятор включается».

    window.onload = function(){/*бла-бла-бла*/}
    В большинстве случаев лучше просто ставить свой <script> перед </body>. window.onload нужен только если нужно дождаться загрузки всех картинок и других подобных ресурсов; у вас, вроде бы, картинок нет.


  1. dshster
    18.01.2016 15:12
    +4

    Почему Canvas, а не CSS Transform, которые во всех современных браузерах считаются за счет GPU? http://caniuse.com/#feat=transforms2d
    При этом можно было бы выкинуть большую часть кода отвечающую за отрисовку. У меня стойкое ощущение, что статья валялась где-то в закромах последние лет 5 (или дольше, судя по window.onload), вы её нашли, сдули пыль и представили современному продвинутому сообществу.

    И, кстати, не нужно писать, что новичкам пригодиться — нет ничего хуже, чем учить новичков устаревшими практиками. Спасибо.


    1. Impressive_i
      18.01.2016 15:24
      +1

      Статью я написал два дня назад, всё от себя, без всякого копипаста. Сегодня опубликовали её.
      Не претендую на хороший дизайн, не претендую на качественный и идеальный код.
      В статье хотел описать как можно подробнее математическую логику задачи и привести рабочий(зрительной сносный) пример реализации.
      Спасибо вам за ваши замечания, буду разбираться с CSS Transform.


      1. dshster
        18.01.2016 15:47

        Значит вам необходимо навёрстывать свои знания — веб очень динамичная среда, даже оставание на год крайне чувствительно для разработчика. Преобразование чисел времени в градусы слишком тривиальная задача, если вам нравится графика попробуйте поизучать SVG и D3.js, это довольно свежо и востребовательно.


    1. ckr
      18.01.2016 20:48

      В вопросе о web-виджетах нет однозначного ответа, что лучше использовать: Canvas или CSS Transforms.
      Вопрос можно ставить на стадии проектирования. Тут предлагают реализацию. А почему сделано так, а не иначе — можно рассуждать бесконечно.
      Мне тоже ближе CSS Transforms. Но лет 20 назад писал подобное на QBASIC. Там как раз для отрисовки использовал все формулы как автор один-в-один.


    1. AndreyNagih
      19.01.2016 07:29
      +1

      D3 и прочие рисующие библиотеки это, конечно, прекрасные технологии, очень сильно сокращающие время и трудоемкость разработки.
      Но с ними та же проблема, что и с jQuery — они порождают толпы разработчиков, которые умеют библиотеку и не умеют косинусы.
      В данной статье упор именно на математику, стоящую за движением стрелок, и в этом ее главная ценность.


  1. Anisotropic
    18.01.2016 15:35

    А кто мешал демку сделать и закинуть на JSfiddle?


  1. Jabher
    18.01.2016 15:59
    +3

    -setInterval использовать НЕЛЬЗЯ НИКОГДА. Очень частая проблема — человек закрыл ноут, открыл, очередь из стоявших setInterval хлынула, ноут завис.
    -для графики всегда стоит использовать requestAnimationFrame
    -перерисовывать весь циферблат логикой не оптимально, правильней рисовать только стрелки, для «нахлестывающихся» картинок стоит либо сделать «двуслойный» прозрачный канвас (поддерживается не везде), либо перерисовывать только «задетые» изображения + рисовать белым поверх старых стрелок, либо изначально создать изображение с помощью канваса, а затем каждый раз делать «кладем нижний слой, кладем стрелки, кладем пимпочку»

    и статья и правда не очень актуальная. такие писались где-то года 3 назад, сейчас же низкоуровневая работа с canvas редкость.


    1. k12th
      18.01.2016 17:10

      Ну, кому-то надо уметь и на низком уровне с канвасом работать. Тем более статья для новичков.


      1. Jabher
        19.01.2016 13:45
        -1

        Новичку лучше начать с простой библиотеки, типа pixi.js.


        1. k12th
          19.01.2016 13:56

          Документация «простой библиотеки». И три с половиной туториала.


          1. Jabher
            19.01.2016 14:24
            -1

            Прекрасный официальный 101 по старой версии (API изменился ну от силы на 5%) — первая же ссылка в гугле
            www.goodboydigital.com/pixi-js-tutorial-getting-started

            Документацию они усложнили, да, ладно, не спорю. Раньше очень простой гайд был.


  1. Aingis
    18.01.2016 16:45

    Что-то больно сложно. Часы и на CSS давно можно написать http://habrahabr.ru/post/171015/


    1. k12th
      18.01.2016 17:12
      +1

      Цель-то не часы сделать (эка невидаль), а дать какие-то первичные навыки работы с канвасом. На практике часы ни на канвасе, ни на CSS сто лет никому не сдались:)


      1. dshster
        18.01.2016 17:33

        https://time.yandex.ru/ с вами не согласится, но, конечно, это единичный случай, да и часы там на SVG. А если автору нравится Canvas, то ему путь в WebGL или какую-то другую сложную пиксельную графику. Для остального есть более высокоуровневые инструменты.


        1. k12th
          18.01.2016 17:36

          Да причем тут WebGL? Начинающий поделился с начинающими туториалом соответствующего уровня.


  1. kahi4
    19.01.2016 14:12

    По математике

    var t_hour = 30*(d.getHours() + (1/60)*d.getMinutes());  //Определяем угол для часов
    


    Что ж сразу и секунды не добавить? Да, будет практически не видно, но зато корректнее.

    var t_sec = 6*d.getSeconds(); 
    var t_min = 6*(d.getMinutes() + (1/60)*d.getSeconds()); // вы уже посчитали угол поворота секунд. 
    
    // better
    
    var t_min = 6 * d.getMinutes() + t_sec / 360; // 1 / 60 хороша с точки зрения понимания, но вот весь веб ленится делать казалось бы крошечные оптимизации, а потом 8 ядер не хватает 
    // на всякий случай -- вместо двух "тяжелых" операций всего одна.
    


    Если использовать requestAnimationFrame, мы можем получать текущее время с точностью до миллисекунд. Это позволит нам добавить easing и получить эффект «дерганья» секундной стрелки. Тут можно посмотреть код этих функций.

    Если говорить про статью — выше уже написали, какие основные проблемы тут есть. Статьи «для начинающих» всегда хорошо, но не от самих же начинающих. Наилучшие статьи «от начинающих для начинающих» — это когда берут написанный профессионалами в области кусок кода и разбирают, почему сделано так или иначе. Конкретно в данной статье нет ничего нового, это даже не систематизированно в одном месте. Прочитав эту статью, новичок в любом случае полезет читать о канвасе что-то более подробное.

    Ну а «математика», на которую вы ставите акцент, уж простите — 7-8 класс ей богу. Не тензорная алгебра уж точно. Если человек не может самостоятельно посчитать формулу как для текущей секунды получить угол поворота секундной стрелки — ему рано в программирование. (За исключением начальных классов, но в целом статья сложная для них).