Как стать автором
Обновить

Как Canvas украсил QIC

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров458

Всем привет! Меня зовут Виген Мовсисян, я Frontend-разработчик в QIC digital hub. В этой статье я расскажу, как мы внедрили технологию Canvas, какие задачи он помогает решать, что уже успели сделать и какие у нас планы на будущее.

Материал основан на моём докладе с QIC Tech Meetup, полную запись вы можете найти на YouTube.

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

*В текущей версии отсутствует кнопка «Назад» — для возврата в предыдущий раздел воспользуйтесь стандартной кнопкой браузера. 

Итак, начнём погружение в удивительный мир возможностей Canvas.

Первое применение Canvas: ускоряем загрузку изображений

Первая ситуация, в которой мы решили использовать Canvas, возникла по запросу бизнеса: «фотографии на сайте загружаются слишком долго, это критично для пользователей».

В ответ на запрос появилась идея: «Что, если сжимать изображения на клиентской стороне?». Этот подход мог существенно сократить время загрузки, именно здесь нам и пригодился Canvas.

export const compressImageFile = (
  imageFile,
  imageQuality = 0.7,
  maxWidth = 800,
  maxHeight = 600
) => {
  return new Promise((resolve, reject) => {
    const img = new Image()
    const objectURL = URL.createObjectURL(imageFile)
    img.src = objectURL
    img.onload = () => {
      const canvas = document.createElement('canvas')
      let width = img.width
      let height = img.height
      if (width > height) {
        if (width > maxWidth) {
          height *= (maxWidth / width)
          width = maxWidth
        }
      } else {
        if (height > maxHeight) {
          width *= (maxHeight / height)
          height = maxHeight
        }
      }
      canvas.width = width
      canvas.height = height
      const ctx = canvas.getContext('2d')
      ctx.drawImage(img, 0, 0, width, height)
      const type = imageFile.type
      canvas.toBlob((blob) => {
        const fileName = imageFile.name
        const compressedFile = new File([blob], fileName, {
          lastModified: Date.now(),
          type
        })
        URL.revokeObjectURL(objectURL)
        resolve(compressedFile)
      }, type, imageQuality)
    }
    img.onerror = error => {
      URL.revokeObjectURL(objectURL)
      reject(error)
    }
  })
}
  1. Этот код берёт загруженный файл изображения, создаёт из него временный objectURL и загружает его в элемент Image. 

  2. Далее в зависимости от максимальных размеров (maxWidth, maxHeight), масштабируется картинка на временном Сanvas. 

  3. После этого с помощью canvas.toBlob генерируется Blob с заданным качеством (imageQuality), который упаковывается обратно в объект File. 

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

Какие у нас получились результаты? В одном из тестовых примеров исходное изображение весило почти 3 МБ, а после сжатия стало всего 49 КБ. То есть файл уменьшился примерно в 57 раз! Конечно, если картинка изначально занимает 40 КБ, добиться такой же разницы уже не выйдет, но даже там заметна экономия. Важно, что благодаря такому подходу время загрузки изображения можно сократить с 16-18 секунд до 2.

Реализация на vue  — здесь.

Попробовать сжать изображение:

  1. Переходите по ссылке: https://qicconf.netlify.app/ 

  2. Нажимаете “Compress Image

Второе применение Canvas: геймификация 

В какой-то момент мне пришла идея использовать Canvas для небольшого игрового элемента: чтобы пользователи могли скоротать время, если вдруг пропадёт интернет. Ведь часто, когда связь обрывается, человек винит в этом разработчиков или сам сервис. Небольшая мини-игра помогает «смягчить» негативное впечатление и удержать пользователя в приложении, даже если возникли временные проблемы с подключением.

В моем примере — это игра, где нужно управлять машиной и избегать её столкновения с другими.  Посмотреть, как это выглядит можно по ссылке (вкладка Game).

 <canvas
        ref="canvasRef"
        class="game-canvas"
        :width="canvasSize.width"
        :height="canvasSize.height"
    />
  1. Мы создаём элемент <canvas> и связываем его с canvasRef, чтобы в дальнейшем получить доступ к контексту 2D-отрисовки (canvasContext).

  2. Размер задаётся вычисленным свойством canvasSize, которое учитывает ориентацию и размеры экрана (поддержка настольных и мобильных устройств).

Получаем контекст:

  const context = (canvasContext.value =   canvasRef.value!.getContext('2d')!);

Сразу в момент монтирования компонента получаем 2d-контекст (canvasContext.value), чтобы управлять отрисовкой.

В начале каждого цикла игры (gameLoop) мы полностью очищаем Canvas, чтобы не наслаивать объекты друг на друга.

 const clearCanvas = () => {
      canvasContext.value!.clearRect(
          0,
          0,
          canvasContext.value!.canvas.width,
          canvasContext.value!.canvas.height
      );
    };

2.1. Создаем игровые объекты

В нашем случае это автомобили и дорога. Начнём с прорисовки машин.

  1. Хранение контекста: в конструктор передаётся CanvasRenderingContext2D, чтобы каждый объект «знал», где себя отрисовывать.

  2. Загрузка изображения: в конструкторе создаётся Image, загружается исходник (imageSrc). После загрузки мы задаём width и height с учётом масштаба, разного для мобильной и десктопной версий (CAR_SCALE_MOB и CAR_SCALE).

  3. Метод move(x, y): меняем координаты машины, чтобы она двигалась или генерировалась в случайной точке.

  4. Метод draw(): вызывает context.drawImage(...) и выводит картинку на Canvas.

class Car {
  context: CanvasRenderingContext2D;
  x: number;
  y: number;
  width = 0;
  height = 0;
  velocityX = 0;
  speed = PLAYER_CAR_SPEED;
  friction = PLAYER_CAR_FRICTION;
  image: HTMLImageElement;

  constructor (context: CanvasRenderingContext2D, imageSrc: string, isMobile: boolean) {
    this.context = context;
    this.x = 0;
    this.y = 0;
    this.image = new Image();
    this.image.src = imageSrc;

    this.image.onload = () => {
      const scale = isMobile ? CAR_SCALE_MOB : CAR_SCALE;
      this.width = this.image.width * scale;
      this.height = this.image.height * scale;
    };
  }
move (x: number, y: number): void {
    this.x = x;
    this.y = y;
  }

  draw (): void {
    if (this.width && this.height) {
      this.context.drawImage(this.image, this.x, this.y, this.width, this.height);
    }
  }
}

Переходим к созданию игровой дороги drawRoad

  1. Заполняем фон светло-серым (#f1f4f6), создавая «дорожное» полотно.

  2. Рисуем центральную разделительную линию с помощью setLineDash.

  3. roadOffset используется, чтобы линия «прокручивалась» вниз, создавая эффект движения.

 const drawRoad = () => {
      const context = canvasContext.value!;
      const roadWidth = context.canvas.width;

      context.fillStyle = '#f1f4f6';
      context.fillRect(0, 0, roadWidth, context.canvas.height);

      roadOffset.value =
          (roadOffset.value + gameSpeed.value + 1) % (ROAD_LINE_HEIGHT + ROAD_GAP_HEIGHT);

      context.strokeStyle = '#5927ff';
      context.lineWidth = ROAD_LINE_WIDTH;
      context.setLineDash([ROAD_LINE_HEIGHT, ROAD_GAP_HEIGHT]);
      context.beginPath();
      context.moveTo(roadWidth / 2, roadOffset.value - (ROAD_LINE_HEIGHT + ROAD_GAP_HEIGHT));
      context.lineTo(roadWidth / 2, context.canvas.height);
      context.stroke();
    };

2.2. Прорисовка на каждом кадре gameLoop

 const gameLoop = () => {
      clearCanvas();
      drawRoad();
      updateEnemyCars();
      isMobile.value ? updatePlayerCarInMobile() : updatePlayerCar();
      handleCollisions();
      requestAnimationFrame(gameLoop);
    };
  1. clearCanvas(): сначала чистим холст.

  2. drawRoad(): отображаем фоновую дорогу.

  3. updateEnemyCars(): сдвигаем машины-противников вниз на скорость gameSpeed и снова их рисуем.

  4. updatePlayerCar() или updatePlayerCarInMobile(): управление машиной игрока (разные методы ввода — клавиатура или джойстик).

  5. handleCollisions(): проверяем столкновения игрока с вражескими машинами.

  6. requestAnimationFrame(gameLoop): рекурсивно вызываем gameLoop для непрерывной анимации ~60 кадров в секунду.

2.3. Проверка столкновений handleCollisions

const handleCollisions = () => {
  enemyCars.value.forEach((enemyCar) => {
    const player = playerCar.value!;
    const hasCollisionX =
        (enemyCar.x > player.x && enemyCar.x < player.x + player.width) ||
        (enemyCar.x + enemyCar.width > player.x &&
            enemyCar.x + enemyCar.width < player.x + player.width);
    const hasCollisionY = enemyCar.y + enemyCar.height > player.y;
    if (hasCollisionX && hasCollisionY) {
      if (window.navigator && window.navigator.vibrate) {
        window.navigator.vibrate(200);
      }
      gameSpeed.value = 0;
      crashMessage.value =
          'Don\'t worry, we cover such cases with insurance.';
    }
  });
};
  1. Сначала смотрим, пересекаются ли машины по оси X (учитывая ширину объектов).

  2. Затем проверяем, не накрыл ли противник игрока по оси Y.

  3. При коллизии игра останавливается (gameSpeed.value = 0), показываем сообщение с кнопкой «Try again».

  4. Если устройство поддерживает вибрацию (navigator.vibrate), вызываем её на 200 мс.

2.4. Запуск игры и динамическое ускорение

gameLoop ();
gameSpeedIncreaseInterval = setInterval(() => {
        const acceleration = isMobile.value ? 0.1 : 1;
        gameSpeed.value = Math.min(gameSpeed.value + acceleration, MAX_GAME_SPEED);
      }, SPEED_INCREMENT_INTERVAL_MS);
  1. С помощью requestAnimationFrame запускаем бесконечный цикл gameLoop.

  2. Каждые 5 секунд SPEED_INCREMENT_INTERVAL_MS немного увеличиваем game Speed, но не выше максимума MAX_GAME_SPEED.

  3. Таким образом, чем дольше идет игра, тем сложнее избежать столкновений.

2.5. Перезапуск и очистка

 const resumeGame = () => {
      const context = canvasContext.value!;
      playerCar.value!.move(
          context.canvas.width / 2,
          context.canvas.height - PLAYER_CAR_START_Y_OFFSET
      );
      playerCar.value!.velocityX = 0;

      enemyCars.value.forEach((enemyCar) => {
        enemyCar.move(
            getRandom(0, context.canvas.width),
            getRandom(-context.canvas.height, 0)
        );
      });

      gameSpeed.value = INITIAL_GAME_SPEED;
      crashMessage.value = '';
    };
  1. Возвращаем машину игрока в начальную точку, сбрасываем её скорость и убираем сообщение об аварии.

  2. Противники тоже «сбрасываются» выше экрана в случайных позициях.

  3. gameSpeed возвращается к начальному значению, игра продолжается.

2.6. Очистка при размонтировании

 onUnmounted(() => {
      document.body.removeEventListener('keydown', handleKeydown);
      document.body.removeEventListener('keyup', handleKeyup);
      clearInterval(gameSpeedIncreaseInterval);
    });

Когда компонент удаляется из DOM, снимаем все слушатели событий клавиатуры и останавливаем интервал ускорения. Это предотвращает утечки памяти и конфликтующие события при смене компонентов или переходе на другую страницу.

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

Недавно я показывал эту игру в компании, и ко мне обратились как Product Owner, так и Team Lead с предложением интегрировать подобную геймификацию в наш проект. 

Текущая версия далека от продакшна, она демонстрирует функциональность и то, как это может работать. В игру можно поиграть по ссылке (вкладка Game).

Код для игры тут.

Третье применение Canvas — рисование

Ещё одна возможность Canvas  — это «холст» для рисования. Мы реализовали эту функцию в следующих форматах: 

  1. Онлайн-подпись: чтобы пользователь мог быстро оставить подпись, не скачивая дополнительные приложения.

  2. Интерактивные промо-акции: вместо обычных форм предлагаем пользователям нарисовать символ, логотип или даже собственный рисунок, за лучшие работы даём скидки или особые бонусы.

Таким образом, «рисовалка» открывает простор для креатива и добавляет вовлечённости в разные пользовательские сценарии.

3.1. Логика рисования

const canvas = ref <HTMLCanvasElement | null>(null);
сonst isDrawing = ref <boolean> (false);
сonst context = ref <CanvasRenderingContext2D | null> (null);
сonst lastX = ref<number>(0);
сonst lastY = ref <number>(0);
  1. canvas: ссылка на сам элемент <canvas>.

  2. context: хранит полученный 2d-контекст, через который мы рисуем на Canvas.

  3. isDrawing: индикатор, включён ли режим «рисования» (пользователь удерживает мышь/палец).

  4. lastX, lastY: координаты предыдущей точки, откуда проводим линию до текущей.

3.1. Получение координат

const getCoordinates = (event: MouseEvent | TouchEvent): { x: number, y: number } => {
  if (event instanceof MouseEvent) {
    return { x: event.offsetX, y: event.offsetY };
  } else if (event instanceof TouchEvent && canvas.value) {
    const rect = canvas.value.getBoundingClientRect();
    const touch = event.touches[0];
    return {
      x: touch.clientX - rect.left,
      y: touch.clientY - rect.top,
    };
  }
  return { x: 0, y: 0 };
};
  1. Если это MouseEvent, координаты берем из event.offsetX/event.offsetY.

  2. Если это TouchEvent на мобильном устройстве, считаем позицию первого тача (touches[0]) относительно верхнего левого угла канвы (canvas.getBoundingClientRect()).

3.2. Начало рисования startDrawing

const startDrawing = (event: MouseEvent | TouchEvent) => {
  if (!canvas.value || !context.value) return;
  isDrawing.value = true;
  const { x, y } = getCoordinates(event);
  [lastX.value, lastY.value] = [x, y];
};
  1. Помечаем, что пользователь начал рисовать (isDrawing.value = true).

  2. Запоминаем координаты начальной точки (lastX.value, lastY.value), чтобы потом провести линию оттуда к следующей точке.

3.3. Рисование при перемещении draw

const draw = (event: MouseEvent | TouchEvent) => {
  if (!isDrawing.value || !canvas.value || !context.value) return;
  event.preventDefault();
  const { x, y } = getCoordinates(event);
  context.value.strokeStyle = 'white';
  context.value.lineJoin = 'round';
  context.value.lineCap = 'round';
  context.value.lineWidth = 5;
  context.value.beginPath();
  context.value.moveTo(lastX.value, lastY.value);
  context.value.lineTo(x, y);
  context.value.stroke();
  [lastX.value, lastY.value] = [x, y];
};
  1. Выполняется только если isDrawing.value === true, то есть кнопка мыши или палец удерживаются.

  2. Настройки кисти:

  • strokeStyle = 'white' (цвет линии),

  • lineJoin и lineCap в round (закруглённые углы и концы),

  • lineWidth = 5 (толщина линии).

  • beginPath(), moveTo(...), lineTo(...) создают линию от предыдущей точки (lastX, lastY) до новых координат (x, y).

  1. Наконец, stroke() прорисовывает эту линию.

  2. Обновляем lastX, lastY, чтобы следующий отрезок начинался там, где закончился предыдущий.

3.4. Остановка рисования stopDrawing

const stopDrawing = () => {
isDrawing.value = false;
};

Сбрасывает флаг isDrawing, когда пользователь отпустил кнопку мыши/палец или вышел за пределы <canvas>.

Саммари по рисованию с помощью Canvas:

  • При нажатии/касании (startDrawing) мы запоминаем стартовую точку, включаем режим рисования.

  • При перемещении мыши или пальца (draw) ведём линию от предыдущей позиции к новой, используя API Canvas (beginPath, lineTo, stroke).

  • При отпускании (stopDrawing) завершаем рисование.

  • Кнопка «Clear Canvas» позволяет быстро очистить холст.

Весь код можно посмотреть здесь.
Порисовать можно тут (вкладка Drawing).  

Четвертое применение Canvas — визуализация звука

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

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

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

4.1. Основные переменные и ссылки

const canvas = ref<HTMLCanvasElement | null>(null);
const isListening = ref<boolean>(false);

let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let microphone: MediaStreamAudioSourceNode | null = null;
let dataArray: Uint8Array;
let animationFrameId: number | null = null;
  1. canvas: ссылка на элемент <canvas>, чтобы далее получить его контекст рисования.

  2. isListening: указывает, идёт ли в данный момент захват звука (и визуализация).

  3. audioContext: объект Web Audio API, управляющий всем, что связано со звуком.

  4. analyser: специальный узел (AnalyserNode), из которого можно читать данные для построения волны.

  5. microphone: источник аудиопотока, полученного от микрофона.

  6. dataArray: массив байтов, куда analyser записывает текущие значения звуковой волны (time domain).

  7. animationFrameId: идентификатор анимации, чтобы можно было остановить перерисовку.

4.2. Визуализация звука drawVisualizer

const drawVisualizer = () => {
  if (!canvas.value || !analyser) return;

  const canvasCtx = canvas.value!.getContext('2d');
  if (!canvasCtx) return;

  const width = canvas.value.width;
  const height = canvas.value.height;

//Настраиваем AnalyserNode

  analyser.fftSize = 2048;
  const bufferLength = analyser.frequencyBinCount;
  dataArray = new Uint8Array(bufferLength);

//Функция, которая будет вызываться каждый кадр
  const draw = () => {
    if (!canvas.value || !analyser) return;
    analyser.getByteTimeDomainData(dataArray);

//Очищаем холст и заливаем фоном
    canvasCtx.clearRect(0, 0, width, height);
    canvasCtx.fillStyle = 'rgb(200, 200, 200)';
    canvasCtx.fillRect(0, 0, width, height);

//Параметры рисования линии
    canvasCtx.lineWidth = 2;
    canvasCtx.strokeStyle = 'rgb(0, 0, 0)';

    canvasCtx.beginPath();

//sliceWidth - ширина шага между точками
    const sliceWidth = (width * 1.0) / bufferLength;
    let x = 0;

// Проходимся по массиву и рисуем колебания
    for (let i = 0; i < bufferLength; i++) {
      const v = dataArray[i] / 128.0;
      const y = (v * height) / 2;

      if (i === 0) {
        canvasCtx.moveTo(x, y);
      } else {
        canvasCtx.lineTo(x, y);
      }

      x += sliceWidth;
    }
// Завершаем линию на середине по высоте
    canvasCtx.lineTo(canvas.value.width, canvas.value.height / 2);
    canvasCtx.stroke();

//Запускаем слудующий кадр
    animationFrameId = requestAnimationFrame(draw);
  };

  draw();
};
  1. Получаем canvasCtx — контекст 2D-рисования.

  2. Устанавливаем fftSize — длину массива данных (у нас 2048).

  3. Создаём массив dataArray нужного размера и просим AnalyserNode заполнить его значениями текущих амплитуд (getByteTimeDomainData).

В цикле draw():

  1. Очищаем холст и заливаем фоновым цветом.

  2. Используя полученные значения dataArray, строим линию, где ось X — это индекс массива, а ось Y — нормализованная амплитуда.

  3. Рекурсивно вызываем requestAnimationFrame(draw), чтобы обновлять картинку в режиме реального времени.

По визуализации звука с помощью Canvas можно подвести следующие итоги:

  • Ключевой элемент — это массив dataArray, в который анализатор (AnalyserNode) помещает выборки текущего сигнала. Для тихих участков значения данных близки к «середине» (около 128), а любые отклонения выше или ниже этой точки указывают на звук. 

  • На Canvas рисуем каждую из этих точек и соединяем их линиями, получая волновую форму. 

  • В итоге пользователь может нажать «Start», заговорить в микрофон и увидеть динамическую визуализацию своего голоса в реальном времени.

Попробовать фичу можно здесь (вкладка Voice Visualizer).
Посмотреть реализацию в коде тут

Canvas умеет и в 3D

Canvas позволяет создавать трёхмерную графику и анимации. Например, в QIC App успешно используют 3D-модели машин. Я глубоко в это не погружался, но важно понимать, что Canvas (в частности, через WebGL) действительно позволяет реализовать такие возможности. Однако, если для вас критична быстрая загрузка, иногда разумнее использовать обычное видео. Интерактивные 3D-элементы стоит добавлять только в случаях, когда это действительно нужно, возможно, при помощи библиотеки Three.js.

У меня была идея загрузить большую GIF-анимацию, но её объём достигал 800 мегабайт. Чтобы не перегружать страницу, я пошёл другим путём и встроил через iframe ссылку на 3D-модель (например, аналог автомобиля Volkswagen). Если перейти по ней, можно убедиться, насколько мощные эффекты 3D-графики достижимы в вебе.

Вероятно, вы слышали о сайте Awwwards. Там номинируют крутые веб-проекты, часто использующие параллакс, 3D и необычные визуальные эффекты. Очень советую заглянуть туда — можно найти действительно вдохновляющие примеры.

Заключение

Мы в QIC используем Canvas для многих задач: для ускорения загрузки изображений, для создания интерактивных элементов и игровых механик, и это лишь начало. Canvas открывает перед нами огромные возможности — от простых эффектов до сложных анимаций и внедрения 3D-моделей. И я с нетерпением жду новых идей и сценариев, которые мы сможем воплотить в будущих проектах.

А как бы вы применили Canvas? Делитесь идеями в комментариях — вместе мы сможем раскрыть его потенциал ещё больше!

Github I Demo 

Теги:
Хабы:
+8
Комментарии0

Публикации

Работа

Ближайшие события