[recovery mode] Визуальный конфигуратор окон, написанный за один час

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

UPD. Добавил скриншоты.UPD2. Развернул демо-версию.

Спасибо за отклики!

Интервьюирую заказчика.1. Это модуль для сайта, который должен работать в произвольных популярных кейсах.2. В режиме редактирования программа должна позволять указывать количество и расположение проемов в окнах.3. В режиме редактирования программа должна позволять указывать способ открывания проемов в окнах, пять вариантов: нет открывания, налево, направо, налево и откидывается, направо и откидывается.4. В режиме отображения программа должна картинкой в произвольном масштабе отображать конфигурацию окна.5. Не нужно хранить и работать со сведениями о размере, пропорциях, цвете и других характеристиках окна. Картинки должны быть цветными и понятными. ЕСКД в данном случае не при делах.6. Не должно глючить, тупить, должно быть кроссбраузерно, должно работать на в браузерах планшетных ПК и на смартфонах и т.д.

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

Теперь дополняем бизнес-требования техническими условиями, для того, чтобы в итоге сформировать техническое задание.1. Изходя из требования произвольного масштабирования — возникает понимание, что графика должна быть векторной. Кроссбраузерное решение, которое удовлетворит — HTML5 canvas.2. Очевидно, должно быть два режима: режим редактирования и режим отображения.3. В режиме редактирования данные должны сохраняться в input type=hidden. Я не буду вносить изменений в CMS — зачем мне лишние головняки? Просто добавлю одно поле в формы для добавления и редактирования, в СУБД и в соответствующие модели (у меня реально это происходит одним действием, если у вас нет — вероятно имеет смысл пересмотреть структуру программы).4. В режиме редактирования ранее созданная визуальная конфигурация окна должна восстанавливаться из данных, находящихся и подставленных автоматически в поле input type=hidden.5. В режиме отображения CMSка отдаст данные, как свойство какого-нибудь div, и моя программа должна эти данные: а) обнаружить, б) нарисовать по ним окно.В данном случае спецификацию я делать не буду, а пойду по пути наименьшего сопротивления. Хорошая часть видения решения присутствует уже на данный момент, поэтому я начну реализацию немедленно. Суровая программисткая реальность: не хочу усложнять себе жизнь, и поэтому изначально создаю масштабируемые и сопровождаемые решения. Поэтому DRY, поэтому абстракции и слои — сразу, по умолчанию.Когда просматривал разновидности окон, зарисовал в тетрадке карандашом небольшой каталог, чтобы понять, что предстоит рисовать. Когда я делал эти зарисовки, пришло понимание, что я не хочу делать это на CSS (вероятно зря), и продолжать работать с .Иду искать библиотеку для работы с canvas. Нахожу calebevans.me/projects/jcanvas/, бегло просматриваю документацию, оцениваю качество исходников и понимаю, что это то, что мне нужно сейчас.Понимаю, что рисование будет самой низкоуровневой функцией. И вообще, давно хочется порисовать. Пробую несколько функций по документации, нахожу примеры онлайн в песочнице. Все работает, все устраивает.

Создам функцию-основу для рисования окна. function windows_init (selector) { window_canvas = $(''). attr ('width', window_width). attr ('height', window_height). attr ('background','blue'). insertAfter (selector); } Естественно, функции не хранят параметры (это называется данными). Внутри функций — переменные.В тот момент совесть не просыпалась, поэтому они в глобальной области видимости. Если она проснется — просто положу все в класс. Если проснется одновременно с ленью (или здравым смыслом) — буду писать на CoffeeScript. Сейчас звезды встали в определенное положение, и есть некоторое понимание того, что конечный продукт будет маленькой программой, состоящей из десятка фунций jQuery, в связи с чем целесообразность подобных действий в настоящий момент просто не рассматривается. Сначала сделать, чтобы работало. Рефакторинг — потом.Глядя на свои зарисовки, вижу, что я могу рисовать оконные проемы, как прямоугольники, и обозначать открывание с помощью ровных ломаных линий внутри них. function make_leaf (canvas, x, y, width, height, window) { canvas.drawRect ({ layer: true, strokeStyle: window_silver, fillStyle: window_blue, strokeWidth: 1, x: x, y: y, width: width, height: height, fromCenter: false, }); } Теперь — линии, обозначающие открывание. Left — налево, right — направо, tilt — откидывание. Кейса с фрамугой вниз нет (переспрашивал, когда интервьюировал заказчика), поэтому и заморачиваться сейчас не буду. Если возникнет потребность — потом можно будет легко его добавить.

// window opening draw function open_left (canvas, x, y, width, height) { canvas.drawLine ({ strokeStyle: window_gray, strokeWidth: 1, x1: x, y1: y, x2: x + width, y2: y + (height / 2), x3: x, y3: y + height, }); }

function open_right (canvas, x, y, width, height) { canvas.drawLine ({ strokeStyle: window_gray, strokeWidth: 1, x1: x + width, y1: y, x2: x, y2: y + (height / 2), x3: x + width, y3: y + height, }); }

function tilt (canvas, x, y, width, height) { canvas.drawLine ({ strokeStyle: window_gray, strokeWidth: 1, x1: x, y1: y + height, x2: x + (width / 2), y2: y, x3: x + width, y3: y + height, }); } Пишу несколько очень быстрых тестов, чтобы попробовать это. Все работает, поэтому перехожу дальше.

Собственно, по конфигурации проемов все окна можно поделить на «вертикальные» (как обычно делают в квартирах), Т-образные. Реже встречаются «горизонтальные» — в подъездах и в учреждениях.Сначала нарисую что-нибудь попроще. Параметр leafs — количество проемов. function window_vertical (canvas, x, y, width, height, leafs, window) { var leaf = width / leafs; for (var i = 0; i < leafs; i++) { var leaf_x = x + (leaf * i); var leaf_y = y; var leaf_width = leaf; var leaf_height = height; var leaf_num = i; make_leaf(canvas, leaf_x, leaf_y, leaf_width, leaf_height, window, leaf_num); } } Посредством небольшой отладки и серии мелких тестов привожу функцию в рабочий вид.Руками передаю параметры и вызываю функции, рисующие открывание – для того, чтобы сверху отображались ломанные линии.Поворачиваю на 90 градусов, и получаю "горизонтальное” окно.

function window_horisontal (canvas, x, y, width, height, leafs, window) { var leaf = height / leafs; for (var i = 0; i < leafs; i++) { var leaf_x = x; var leaf_y = y + (leaf * i); var leaf_width = width; var leaf_height = leaf; var leaf_num = i; make_leaf(canvas, leaf_x,leaf_y,leaf_width, leaf_height, window, leaf_num); } } Тестирую, добиваюсь работоспособности.Красивая пропорция – 1 к 2. Так как в бизнес-требованиях есть указание не заморачиваться с пропорциями, для Т-образного окна сделаю вот такой дизайн.

function window_t (canvas, x, y, width, height, leafs, window) { var w = width / leafs; make_leaf (canvas, x, y, width, height / 3, window, 0); for (var i = 0; i < leafs; i++) { var leaf_x = x + (w * i); var leaf_y = y + (height / 3 ); var leaf_width = w; var leaf_height = height * 2 / 3; var leaf_num = i + 1; make_leaf(canvas, leaf_x,leaf_y,leaf_width, leaf_height, window, leaf_num); } } Делаю тесты, заставляю все работать ровно, без рывков.

Нарисую все виды окон, с которыми должна работать программа. function windows_catalog () { window_horisontal ( window_canvas, 0, padding, catalog_height, catalog_height, 1, {type: 'single', leafs: 1, from: 'catalog'}); var offset = catalog_height + padding; for (var i = 2; i < 5; i++) { window_vertical( window_canvas, offset, padding, catalog_height * (i / 2), catalog_height, i, {type: 'vertical', leafs: i, from: 'catalog'}); offset += padding + (catalog_height * (i / 2)); } window_horisontal( window_canvas, offset, padding, catalog_height, catalog_height, 2, {type: 'horisontal', leafs: 2, from: 'catalog'}); offset += padding + catalog_height; for (var i = 0; i < 3; i++) { window_t( window_canvas, offset, padding, catalog_height, catalog_height, i + 2, {type: 't', leafs: i + 2, from: 'catalog'}); offset += padding + catalog_height } } Седьмой параметр и понимание его содержание добавились позднее. Просто не обращайте на него внимание сейчас.И добавлю в функцию, ответственные за рисование створки окна, коллбек на клик. Промежуточная версия кода не сохранилась – взяв хороший разгон, я позабыл делать частые комиты, поэтому покажу окончательную версию.

function make_leaf (canvas, x, y, width, height, window, leaf_num) { canvas.drawRect ({ layer: true, strokeStyle: window_silver, fillStyle: window_blue, strokeWidth: 1, x: x, y: y, width: width, height: height, fromCenter: false, click: function (layer) { leaf_clicked (window, leaf_num) } }); } И функция, которая ловит клик по створке большого окна или маленькому окну в каталоге.

function leaf_clicked (window, leaf_num) { if (! window) { return; } window_canvas.clearCanvas (); windows_catalog (); if (window.size == 'big') { trigger_opening (leaf_num); } big_window (window.type, window.leafs); } Была мысль сделать раздельные коллбеки, но в процессе причин для совершения лишней работы не нашел.Добавил функцию-диспетчер, для удобства.

function opening (canvas, x, y, width, height, num) { switch (window_opening[num]) { case 'left': open_left (canvas, x, y, width, height); break; case 'left tilt': open_left (canvas, x, y, width, height); tilt (canvas, x, y, width, height); break; case 'right': open_right (canvas, x, y, width, height); break; case 'right tilt': open_right (canvas, x, y, width, height); tilt (canvas, x, y, width, height); break; } }

Открывание створок будет переключаться щелчком. Что может быть проще? Сохраню в массиве список створок, и определю во втором массиве возможности по их открыванию. // window opening var window_opening = []; var opening_order = ['none', 'left tilt', 'right tilt', 'left', 'right']; Заполню массив данными по умолчанию. Не лучший вариант, но на момент написания думал о другом — о вероятном сохранении данных. function set_opening (leaf_count) { for (var i = 0; i < leaf_count; i++) { window_opening.push(opening_order[0]); } } По щелчку должно меняться открывание створки. В цикле по возможностям открывания: нет, налево, направо, налево и откидывается, направо и откидывается.

function trigger_opening (num) { var current = opening_order.indexOf (window_opening[num]); if ((current + 2) > opening_order.length) { current = 0; } else { current++; } window_opening[num] = opening_order[current]; window_data (); } И тут же, не уходя далеко…

Данные после редактирования нужно сохранять.Сделаю сериализацию от руки. function window_data () { var string = order.type + '|' + order.leafs; for (var i in window_opening) { string += '|' + window_opening[i]; } var select = $('input[name=«window_type»]'); select.val (string); } И, теперь никто не мешает рисовать окна из сохраненных данных.

function window_from_string (string) { if (! string.length) { return; } var data = string.split ('|'); for (var i = 0; i < 10; i++) { window_opening[i] = data[i + 2]; } big_window(data[0],data[1]); } Конфигурация окон может отрисовываться в списках заказов, это очень удобно. Маленькие картинки.

function small_window_from_string (element, string, width, height) { if (! string.length) { return; } var small_canvas = $(''). attr ('width', width). attr ('height', height). appendTo (element); var data = string.split ('|'); for (var i = 0; i < 10; i++) { window_opening[i] = data[i + 2]; } var leafs = data[1]; switch (data[0]) { case 'single': window_vertical(small_canvas, 0, 0, width, height, leafs, false); break; case 'vertical': window_vertical(small_canvas, 0, 0, width, height, leafs, false); break; case 'horisontal': window_horisontal(small_canvas, 0, 0, width, height, leafs, false); break; case 't': window_t(small_canvas, 0, 0, width, height, leafs, false); break; } }

Программа должна каким-то образом понимать, что настало время рисовать окна.Исходя из ТЗ, есть два варианта — поле формы и в произвольном месте. function windows_handler () { // add or edit var select = $('input[name=«window_type»]'); if (select.length) { select.hide (); windows_init (select); window_from_string (select.val ()); } // show small window $('.magic_make_window').each (function () { small_window_from_string ($(this),$(this).attr ('window'), $(this).width (), $(this).height ()) }); } Пожалуй, input[name=«window_type»] — не лучшее решение. Просто на этот момент у меня была цель запустить программу в работу, и я совсем не хотел модифицировать CMSку — поэтому обучил плагин искать свое поле по его имени: windows_type.

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

Вот переработанный код целиком. Это бета, и она же пошла в продакшн без изменений. $(document).ready (function () { set_opening (10); });

function windows_handler () { // add or edit var select = $('input[name=«window_type»]'); if (select.length) { select.hide (); windows_init (select); window_from_string (select.val ()); } // show small window $('.magic_make_window').each (function () { small_window_from_string ($(this),$(this).attr ('window'), $(this).width (), $(this).height ()) }); }

function small_window_from_string (element, string, width, height) { if (! string.length) { return; } var small_canvas = $(''). attr ('width', width). attr ('height', height). appendTo (element); var data = string.split ('|'); for (var i = 0; i < 10; i++) { window_opening[i] = data[i + 2]; } var leafs = data[1]; switch (data[0]) { case 'single': window_vertical(small_canvas, 0, 0, width, height, leafs, false); break; case 'vertical': window_vertical(small_canvas, 0, 0, width, height, leafs, false); break; case 'horisontal': window_horisontal(small_canvas, 0, 0, width, height, leafs, false); break; case 't': window_t(small_canvas, 0, 0, width, height, leafs, false); break; } }

function window_from_string (string) { if (! string.length) { return; } var data = string.split ('|'); for (var i = 0; i < 10; i++) { window_opening[i] = data[i + 2]; } big_window(data[0],data[1]); }

var window_width = 900; var window_height = 350; var catalog_height = window_width / 18; var padding = 15; var window_canvas;

var window_blue = '#8CD3EF'; var window_silver = 'white'; var window_gray = 'black';

var order = {type: undefined, leafs: undefined};

function window_data () { var string = order.type + '|' + order.leafs; for (var i in window_opening) { string += '|' + window_opening[i]; } var select = $('input[name=«window_type»]'); select.val (string); }

function windows_init (selector) { window_canvas = $(''). attr ('width', window_width). attr ('height', window_height). attr ('background','blue'). insertAfter (selector); windows_catalog (); }

function windows_catalog () { window_horisontal ( window_canvas, 0, padding, catalog_height, catalog_height, 1, {type: 'single', leafs: 1, from: 'catalog'}); var offset = catalog_height + padding; for (var i = 2; i < 5; i++) { window_vertical( window_canvas, offset, padding, catalog_height * (i / 2), catalog_height, i, {type: 'vertical', leafs: i, from: 'catalog'}); offset += padding + (catalog_height * (i / 2)); } //~ for (var i = 2; i < 6; i++) //~ { window_horisontal( window_canvas, offset, padding, catalog_height, catalog_height, 2, {type: 'horisontal', leafs: 2, from: 'catalog'}); offset += padding + catalog_height; //~ } for (var i = 0; i < 3; i++) { window_t( window_canvas, offset, padding, catalog_height, catalog_height, i + 2, {type: 't', leafs: i + 2, from: 'catalog'}); offset += padding + catalog_height } }

function window_t (canvas, x, y, width, height, leafs, window) { var w = width / leafs; make_leaf (canvas, x, y, width, height / 3, window, 0); for (var i = 0; i < leafs; i++) { var leaf_x = x + (w * i); var leaf_y = y + (height / 3 ); var leaf_width = w; var leaf_height = height * 2 / 3; var leaf_num = i + 1; make_leaf(canvas, leaf_x,leaf_y,leaf_width, leaf_height, window, leaf_num); if (window.from != 'catalog') { opening(canvas, leaf_x,leaf_y,leaf_width, leaf_height, leaf_num); } } }

function window_vertical (canvas, x, y, width, height, leafs, window) { var leaf = width / leafs; for (var i = 0; i < leafs; i++) { var leaf_x = x + (leaf * i); var leaf_y = y; var leaf_width = leaf; var leaf_height = height; var leaf_num = i; make_leaf(canvas, leaf_x, leaf_y, leaf_width, leaf_height, window, leaf_num); if (window.from != 'catalog') { opening(canvas, leaf_x, leaf_y, leaf_width, leaf_height, leaf_num); } } }

function window_horisontal (canvas, x, y, width, height, leafs, window) { var leaf = height / leafs; for (var i = 0; i < leafs; i++) { var leaf_x = x; var leaf_y = y + (leaf * i); var leaf_width = width; var leaf_height = leaf; var leaf_num = i; make_leaf(canvas, leaf_x,leaf_y,leaf_width, leaf_height, window, leaf_num); if (window.from != 'catalog') { opening(canvas, leaf_x,leaf_y,leaf_width, leaf_height, leaf_num); } } }

function make_leaf (canvas, x, y, width, height, window, leaf_num) { canvas.drawRect ({ layer: true, strokeStyle: window_silver, fillStyle: window_blue, strokeWidth: 1, x: x, y: y, width: width, height: height, fromCenter: false, click: function (layer) { leaf_clicked (window, leaf_num) } }); }

function big_window (window_type, leafs) { var padding_top = catalog_height + (padding * 2); if (window_width > window_height) { var segment = window_height — padding_top; } //~ else //~ { //~ var segment = (window_width — catalog_height — (padding * 3)) / 2; //~ } order.type = window_type; order.leafs = leafs; window_data (); switch (window_type) { case 'single': window_vertical ( window_canvas, 0, padding_top, segment, segment, leafs, {type: 'single', leafs: 1, size: 'big'}); break; case 'vertical': window_vertical ( window_canvas, 0, padding_top, segment /2 * leafs, segment, leafs, {type: 'vertical', leafs: leafs, size: 'big'}); break; case 'horisontal': window_horisontal ( window_canvas, 0, padding_top, (segment * 2) / leafs, segment, leafs, {type: 'horisontal', leafs: leafs, size: 'big'}); break; case 't': window_t ( window_canvas, 0, padding_top, segment, segment, leafs, {type: 't', leafs: leafs, size: 'big'}); break; } }

function leaf_clicked (window, leaf_num) { if (! window) { return; } window_canvas.clearCanvas (); windows_catalog (); if (window.size == 'big') { trigger_opening (leaf_num); } big_window (window.type, window.leafs); }

function opening (canvas, x, y, width, height, num) { switch (window_opening[num]) { case 'left': open_left (canvas, x, y, width, height); break; case 'left tilt': open_left (canvas, x, y, width, height); tilt (canvas, x, y, width, height); break; case 'right': open_right (canvas, x, y, width, height); break; case 'right tilt': open_right (canvas, x, y, width, height); tilt (canvas, x, y, width, height); break; } }

// window opening draw function open_left (canvas, x, y, width, height) { canvas.drawLine ({ strokeStyle: window_gray, strokeWidth: 1, x1: x, y1: y, x2: x + width, y2: y + (height / 2), x3: x, y3: y + height, }); }

function open_right (canvas, x, y, width, height) { canvas.drawLine ({ strokeStyle: window_gray, strokeWidth: 1, x1: x + width, y1: y, x2: x, y2: y + (height / 2), x3: x + width, y3: y + height, }); }

function tilt (canvas, x, y, width, height) { canvas.drawLine ({ strokeStyle: window_gray, strokeWidth: 1, x1: x, y1: y + height, x2: x + (width / 2), y2: y, x3: x + width, y3: y + height, }); }

// window opening

var window_opening = []; var opening_order = ['none', 'left tilt', 'right tilt', 'left', 'right'];

function set_opening (leaf_count) { for (var i = 0; i < leaf_count; i++) { window_opening.push(opening_order[0]); } }

function trigger_opening (num) { var current = opening_order.indexOf (window_opening[num]); if ((current + 2) > opening_order.length) { current = 0; } else { current++; } window_opening[num] = opening_order[current]; window_data (); } Что не показано в статье. Функция windows_handler запускается другим JS-компонентом, по двум событиям: document.ready и успешной загрузке аяксовых данных. Таким образом, окна отрисовываются немедленно после загрузки страницы, и перерисовываются, если происходит интерактивное обновление данных («живой режим»).Все юзкейсы выполняются. Сделал простой тест с большим количеством перерисовываний без перезагрузок, оставил машину с запущенными хромом и мозилой на некоторое время — память не жрется. Погонял этот же тест несколько часов в хроме и в сафари на айпаде и макбуке. Проблем не обнаружено.

Маленькая картинка, создается на клиенте на лету (распечатывается великолепно)0239be4304754c688b516791d83297c1.pngБольшая картинка. Размеры можно и поправить, когда-нибудь.d056ca059300409880cab51ec33e8be5.png

В режиме редактирования. Щелчок на маленькое окошко в каталоге изменяет конфигурацию большого (и сразу же данные в input type=hidden).8d09d814127b4c478f8a638ae95f2f98.png

Щелчок на створку большого окна изменяет открывание створки.94528d04e17a4fd8a5904d7206d43a94.png

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

Хорошо бы засунуть этот код в какую-нибудь песочницу, вместе с тестами. Как вы считаете?

Сообщайте замечания в личку.

Спасибо!

© Habrahabr.ru