HTML5 Canvas - интерактивные фигуры

188

Холст является незапоминаемой (non-retained) поверхностью рисования. Это означает, что холст не отслеживает выполняемые на нем операции рисования, он фиксирует лишь конечный результат этих операций — набор разноцветных пикселей, составляющих его содержимое.

Например, если нарисовать красный квадрат в центре холста методом stroke() или fill(), как только завершится выполнение этого метода, квадрат станет ничем иным, как блоком красных пикселов. Он может казаться вам квадратом, но холст не обладает информацией об этом.

Такой подход позволяет сделать процесс рисования быстрым, но он также усложняет задачу создания интерактивной графики. Допустим, что нам нужно разработать более интеллектуальную версию программы рисования, которую мы рассмотрели в статье "Создание простой программы рисования". В частности, мы хотим, чтобы кроме линий в ней также можно было бы рисовать прямоугольники. (Но это еще легко!)

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

Чтобы решить все эти проблемы и сделать холст интерактивным, нам нужно отслеживать все рисуемые на нем объекты. Потом, когда пользователь щелкнет в какой-либо точке холста, нужно определить, не попал ли он по одной из фигур. Этот процесс называется проверкой попадания (hit testing). Если мы можем решить эти две задачи, решение остальных — модифицирование фигуры и обновление холста соответствующим образом — будет легким.

Отслеживание нарисованного содержимого

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

Для простоты, программа рисует только отдельные круги разного размера и цвета. Чтобы отслеживать отдельный круг, нам нужно знать его позицию на холсте (т.е. координаты центра), радиус и цвет заливки. Вместо того чтобы создавать несколько десятков переменных для хранения всей этой информации, нужно хранить четыре типа данных в одном компактном пакете. Таким пакетом будет пользовательский объект (custom object).

Рассмотрим создание пользовательского объекта на случай, если вам никогда раньше не приходилось выполнять эту стандартную процедуру. Сначала создается функция с названием, отображающим тип нашего объекта. Например, функцию для создания пользовательского объекта для рисования круга можно назвать Circle():

function Circle() {
   }

Мы хотим, чтобы наш объект мог хранить данные. Это делается посредством создания свойств с помощью ключевого слова this. Например, чтобы определить свойство radius объекта круга, присваивается значение выражению this.radius.

Можно пойти далее и передавать параметры функции Circle(). Таким образом создание объекта круга и установка его свойств осуществляется в одном шаге. В следующем коде приводится пример функции Circle(), позволяющей устанавливать параметры:

// Пользовательский объект Circle 
function Circle(x, y, radius, color) {
    this.x = x;
    this.y = y;
    this.radius = radius;
    this.color = color;
    this.isSelected = false;
}

Свойство isSelected принимает значения true или false. Когда пользователь щелкает на круге, свойству isSelected присваивается значение true, вследствие чего код рисования знает, что у данного круга нужно нарисовать другой контур.

Объект круга с помощью этой версии функции Circle() можно создать посредством такого кода:

var myCircle = new Circle(0, 0, 20, "red");

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

// Этот массив хранит все окружности на холсте
var circles = [];

Оставшийся код не представляет ничего сложного. Когда пользователь наживает кнопку "Добавить круг", чтобы создать новый круг, вызывается функция addRandomCircle(), которая создает новый круг произвольного размера и цвета в произвольном месте холста:

function addRandomCircle() {
    // Устанавливаем произвольный размер и позицию круга
    var radius = randomFromTo(10, 60);
    var x = randomFromTo(0, canvas.width);
    var y = randomFromTo(0, canvas.height);

    // Окрашиваем круг произвольным цветом
    var colors = ["green", "blue", "red", "yellow", "magenta", "orange", "brown", "purple", "pink"];
    var color = colors[randomFromTo(0, 8)];

    // Создаем новый круг
    var circle = new Circle(x, y, radius, color);

    // Сохраняем его в массиве
    circles.push(circle);

    // Обновляем отображение круга
    drawCircles();
}

В коде применяется пользовательская функция randomFromTo(), которая генерирует произвольные числа в заданном диапазоне:

function randomFromTo(from, to) {
  return Math.floor(Math.random() * (to - from + 1) + from);
}

Последним шагом в этой последовательности будет собственно прорисовка текущей коллекции кругов на холсте. Для этого после создания нового круга функция addRandomCircle() вызывает функцию drawCircles(), которая в свою очередь перебирает массив кругов с помощью следующего кода:

function drawCircles() {
    // Очистить холст
    context.clearRect(0, 0, canvas.width, canvas.height);

    // Перебираем все круги
    for(var i=0; i<circles.length; i++) {
        var circle = circles[i];

        // Рисуем текущий круг
        context.globalAlpha = 0.85;
        context.beginPath();
        context.arc(circle.x, circle.y, circle.radius, 0, Math.PI*2);
        context.fillStyle = circle.color;
        context.strokeStyle = "black";

        // Выделяем выбранный круг рамкой (потребуется позже)
        if (circle.isSelected) {
            context.lineWidth = 5;
        }
        else {
            context.lineWidth = 1;
        }
        context.fill();
        context.stroke(); 
    }
}

Функция clearCanvas() очищает холст и использует для этого вызов drawCircles() с обнуленным массивом circles[]:

function clearCanvas() {
    // Очистить массив
    circles = [];

    // Очистить холст
    drawCircles();
}

Чтобы использовать функции addRandomCircle() и clearCanvas() добавьте в разметку две кнопки, обрабатывающие событие onclick:

<div class="CanvasContainer">
     <canvas id="drawingCanvas" width="500" height="340"></canvas>
</div>
<div>
     <button onclick="addRandomCircle()">Добавить круг</button>
     <button onclick="clearCanvas()">Очистить холст</button>
</div>

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

В следующем разделе мы рассмотрим, как использовать эту систему, чтобы разрешить пользователю выбирать круг.

Проверка попадания посредством сравнения координат

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

Сложные системы анимации (такие как предоставляемые Flash и Silverlight) облегчают работу разработчика и сами выполняют проверку попадания. Также существуют библиотеки расширения JavaScript для холста (например, Kinetic JS), направленные на предоставление таких удобств, но пока ни одна из них не является достаточно развитой, чтобы ее можно было порекомендовать для применения. Поэтому на данное время фанатам холста нужно разрабатывать собственную логику проверки попадания.

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

Первое, что нам требуется — это цикл для перебора всех фигур. Этот цикл должен перебирать объекты в массиве в обратном порядке — от конца к началу. Проверка начинается с конечного элемента массива (индекс которого равен общему числу объектов в массиве минус единица) и ведет отсчет в обратном направлении к первому элементу (индекс которого равен 0).

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

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

В данном примере веб-страница обрабатывает событие холста onclick, чтобы проверить попадание точки, в которой щелкнули, с кругом. Щелчок по холсту активизирует функцию canvasClick(), которая вычисляет координаты точки, в которой щелкнули, а потом проверяет, не находятся ли они внутри какого-либо круга:

window.onload = function() {
	     // Определение контекста рисования
	     canvas = document.getElementById("drawingCanvas");
         context = canvas.getContext("2d");  
		 
		 canvas.onclick = canvasClick;
}

   ...
   
var previousSelectedCircle;

function canvasClick(e) {
  // Получаем координаты точки холста, в которой щелкнули
  var clickX = e.pageX - canvas.offsetLeft;
  var clickY = e.pageY - canvas.offsetTop;

  // Проверяем, щелкнули ли no кругу
  for(var i=circles.length-1; i>=0; i--) {
    var circle = circles[i];

    // С помощью теоремы Пифагора вычисляем расстояние от 
	// точки, в которой щелкнули, до центра текущего круга
    var distanceFromCenter = Math.sqrt(Math.pow(circle.x - clickX, 2) + Math.pow(circle.y - clickY, 2))
	
	// Определяем, находится ли точка, в которой щелкнули, в данном круге
    if (distanceFromCenter <= circle.radius) {
	  // Сбрасываем предыдущий выбранный круг	
      if (previousSelectedCircle != null) previousSelectedCircle.isSelected = false;
      previousSelectedCircle = circle;

      // Устанавливаем новый выбранный круг и обновляем холст
      circle.isSelected = true;
      drawCircles();
	  
	  // Прекращаем проверку
      return;
    }
  }
} 

Этот пример можно улучшить множеством разнообразных способов и сделать его более интеллектуальным. Например, можно добавить панель инструментов с командами для редактирования выбранного круга, чтобы изменить его цвет или удалить с холста. Или же можно добавить возможность перетаскивания выбранного круга с одного места холста в другое. Для этого нужно просто отслеживать холст на событие onMouseMove, изменить соответствующим образом координаты круга, а потом вызвать функцию drawCircles(), чтобы обновить холст с кругом в новом месте:

window.onload = function() {
	     // Определение контекста рисования
	     canvas = document.getElementById("drawingCanvas");
         context = canvas.getContext("2d");  
		 
		 canvas.onmousedown = canvasClick;   
		 canvas.onmouseup = stopDragging;
         canvas.onmouseout = stopDragging;
         canvas.onmousemove = dragCircle;
}

...

function canvasClick(e) {
  
  ...
  
    if (distanceFromCenter <= circle.radius) {
	 ...
	  
      isDragging = true;
      return;
    }
  }
}

var isDragging = false;

function stopDragging() {
  isDragging = false;
}

function dragCircle(e) {
  // Проверка возможности перетаскивания
  if (isDragging == true) {
    // Проверка попадания
    if (previousSelectedCircle != null) {
      // Сохраняем позицию мыши
      var x = e.pageX - canvas.offsetLeft;
      var y = e.pageY - canvas.offsetTop;

      // Перемещаем круг в новую позицию
      previousSelectedCircle.x = x;
      previousSelectedCircle.y = y;

      // Обновляем холст
      drawCircles();
    }
  }
}

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

Пройди тесты
Лучший чат для C# программистов