Как сделать интерактивные пиксельные изображения с D3.js?

Hola, Amigos! На связи Артем Салеев, технический директор  и Арсений Захаров, frontend-разработчик агентства продуктовой разработки Amiga. Сегодня расскажем, как мы реализовали задачу для крупного заказчика: разместить на сайте «размытые» картинки, которые бы разблюривались по пользовательскому взаимодействию.

Поменяли стили и картинку из-за NDA

Поменяли стили и картинку из-за NDA

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

Потрогать с пользовательской стороны то, что у нас получилось, можно по ссылке:  https://onsh-a.github.io/d3_puzzle/

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

Когда ТЗ было составлено, a требования стали более менее понятны, мы отправились в интернет на поиски референса. Нам повезло и довольно быстро мы наткнулись на такую демку. По большей части это решение удовлетворяло требованиям заказчика, но не всем.

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

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

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

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

Итак первое, что надо было сделать, это получить данные пиксельного представления картинки. Чтобы получить данные о пикселях, мы используем canvas. С помощью метода getImageData получаем пиксельные данные всей области canvas. В свойстве data будет лежать массив с набором цветов в формате RGBA. Если убрать из метода все проверки, то получение данных о цветах будет выглядеть так:

public getColorMatrix(loadedImage: HTMLImageElement | null): void {
  const canvas = document.createElement('canvas');
  canvas.width = this.dim; // сторона квадрата картинки
  canvas.height = this.dim;
  const context = canvas.getContext('2d');
  context.drawImage(loadedImage, 0, 0, this.dim, this.dim);
  return context.getImageData(0, 0, this.dim, this.dim).data;
}

Далее необходимо рассчитать, какого размера будет изображение. Также мы установили ограничение, согласно которому изображения будут строго квадратные. Для десктопов определить размер было несложно. Согласно дизайну, максимальная ширина изображения не должна была составлять больше 512 px. А вот для мобильных устройств рассчитать ширину было несколько сложнее.

Как мы определили заранее, минимальная фракция должна была составлять не более 2pх, а максимальная 32 px. Значение минимальной фракции накладывало следующее ограничение: ширина не может быть нечетным числом. Но это была не единственная загвоздка. Значение наибольшей фракции также влекло проблемы.

Представим, что ширина экрана — 386 px, в которые необходимо поместить картинку. 386 px вместит 12 квадратиков со стороной 32, но останутся квадратики меньшего размера, которые мы не можем объединить в тринадцатый квадрат со стороной 32, потому что он не влезет в экран, поэтому они объединяются в максимально возможный параллелепипед.

В нашем случае он будет 2 px на 32 px для бокового края и 32 px на 2 px для нижнего края, так как изображение — это квадрат. Такая фракция будет едва заметна для глаза и пользователю будет очень сложно попасть по ней пальцем, даже если он заметит её. Поэтому ширина изображения рассчитывалась таким образом, чтобы минимальная фракция была меньше 6 px в ширину или высоту.

ae57d8c9607bb04980f139a1b2420893.png

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

Создали svg, внутри которого будут элементы rect, а каждый элемент будет представлять собой фракцию картинки на определенном слое. Представим, что дана квадратная картинка со стороной n. Она разбивается на квадраты со стороной 2 px. Далее на основании усредненного значения цвета 4 соседних квадратиков мы рассчитываем цветовое значение бóльшего квадрата со стороной 4 px и повторяем эту процедуру, но уже с квадратами со стороной 4, формируя квадраты со стороной 8 px и тд. (2 → 4 → 8 → 16 → 32).

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

Так как подобное решение предполагало большое количество манипуляций с svg элементами в dom дереве, мы решили использовать библиотеку, которая заточена на решение подобных задач — d3.js. Она предоставляет большое количество примитивов для работы и анимации svg изображений из коробки.

Структурно приложение будет состоять из двух классов:

Как мы писали выше, оригинальное решение использовало замыкания. Хотя это решение работало, вносить изменения в код было достаточно сложно из-за того, что замыкания бывают крайне ненаглядными. После того как картинка инициализировалась, получить ее текущее состояние было очень сложно, а значит, процесс отладки превращался в кошмар. Нужно было придумать структуру, с которой работать было бы более удобно, но при этом она не испортила производительность приложения. В качестве подходящей структуры мы выбрали hash map следующего вида:

{
  'layer_0': {
    "0~0": Pixel,
    "2~0": Pixel,
    "4~0": Pixel,
    ...
  },
  'layer_1': {
    "0~0": Pixel,
    "4~0": Pixel,
    "8~0": Pixel,
    ....
  }
  ...
}

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

На первом уровне хэш-мап делится на слои, где layer_0 — это исходный (самый нижний) слой, который состоит из квадратов с самой маленькой стороной — 2, а layer_${n} c наибольшим значением — это конечный слой, который виден пользователю сразу после инициализации программы.

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

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

078f7285e3d05bd3518feb74fe5e61e0.png

Еще одной важной задачей было считать скорость открытия картинки и текущий процент её раскрытия. Для этого, как было указано в предыдущем абзаце, каждому инстансу класса Pixel присваивался свой «вес», который вычитался из общего веса разблюренного изображения при успешном разделении элемента на более мелкие фракции или полном удалении его из svg. Также рассчитывался и процент открытой картинки (это было одной из бизнес-задач согласно утвержденному креативу). Для этого при инициализации приложения мы запускали интервал на секунду, который считал, сколько элементов было раскрыто за данный период, и выводил текущую скорость в условных пикселях в секунду.

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

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

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

  • В изначальной реализации мы сделали так, что финальным слоем (оригинальной картинкой) после 100% разблюривания был первый слой обработанной картинки, то есть картинка в два раза более плохого качества, чем оригинал.

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

Чтобы сделать слой с фракциями более заметным, мы добавили тень к элементу svg, внутри которого сетятся элементы.

Что же касается последней проблемы, тут решение было очень простым, вместо нижнего слоя обработанной картинки мы стали выводить оригинальное изображение. То, что для пользователей выглядит как одна картинка, на самом деле стоит из 2 изображений, как скретч-полоса на лотерейном билете. Нижний слой — нужная картинка. Верхний — ее сгенерированное svg-представление. То, что выглядит для пользователей как всего лишь «пиксельная картинка, которая разблюривается», представляло собой довольно интересное логическое упражнение)

Посмотреть код:  https://github.com/Onsh-a/d3_puzzle

Будем рады обратной связи в комментариях!

a957d61098eddc98f7141ab3f73f9345.pngАрсений Захаров 

Frontend-разработчик Amiga и автор статьи

© Habrahabr.ru