Я только что прошел прекрасную стажировку, работая с командой Google Cloud Service Monitoring. Во время этой стажировки я отвечал за разработку нового многоразового ползунка шкалы времени. При разработке и реализации слайдера я столкнулся с множеством интересных инженерных проблем. В этой статье я проанализирую некоторые из этих проблем и объясню, как они были в конечном итоге решены.

Прежде чем я начну, я хотел бы поблагодарить всю мою команду за то, что они были очень гостеприимны ко мне и научили меня огромному количеству фронтенд-инжиниринга. Есть несколько товарищей по команде, которых я хотел бы особо упомянуть, потому что именно с ними я наиболее тесно сотрудничал. Я хотел бы поблагодарить Дэвида Саутера ([email protected]) за то, что он замечательный хозяин. Проект слайдера временной шкалы был его идеей, и он изложил мне очень четкие требования, которым я должен следовать. Он и Дэйв Раффенспергер ([email protected]) всегда помогали мне с любыми возникающими вопросами программирования. Они упростили мне задачу освоить кодовую базу Google и упростили изучение новых технологий (Angular, TypeScript и RxJS). Я также хотел бы сказать большое спасибо Джуди Подраза ([email protected]), которая была дизайнером UX для слайдера. Работая с ней, я многое узнал о том, как сделать так, чтобы пользовательский интерфейс предоставлял максимально интуитивно понятный и приятный опыт.

Обзор ползунка шкалы времени

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

Основными технологиями, с которыми я работал, были Angular, TypeScript и D3.js. Ползунок временной шкалы - это компонент Angular, который предоставляет простой API через Angular @Inputs и @Outputs (а также структурную директиву). Это позволяет легко интегрировать слайдер в страницы мониторинга сервисов, которые содержат другие компоненты Angular. Ползунок состоит из элементов SVG, которые управляются и анимируются D3.

Выделение выбранного окна синим фоном

Когда выбран временной диапазон, ползунок шкалы времени выделяет его, изменяя цвет фона ползунка на синий между ползунками.

Первой задачей, которую я решил решить, было определить, как ограничить синий фон между двумя лопастями. Первая идея, которая пришла в голову, заключалась в изменении ширины и положения одного синего прямоугольника в зависимости от положения лопастей. Я намеревался переместить начальную позицию синего прямоугольника, чтобы выровняться с левой ракеткой, и сделать прямоугольник достаточно широким, чтобы заканчиваться на правой ракетке. Первоначально казалось, что это решило проблему, но при более внимательном рассмотрении я обнаружил небольшой недостаток. Синий прямоугольник всегда будет нарисован с углами 90 градусов и никогда не закруглен. Это было проблемой, потому что серый фон ползунка имел закругленные углы. Мне нужно было убедиться, что синее выделение всегда соответствовало форме серого фона под ним. Каждый раз, когда выделение начиналось около левого края ползунка или заканчивалось около правого края, мне нужно было сделать выделение выделения с закругленными углами. Однако простая проверка того, было ли положение левого весла рядом с началом ползунка или положение правого весла ближе к концу, не сработало бы. Это связано с тем, что было бы невозможно программно изменить скругление угла синего прямоугольника, чтобы оно соответствовало определенным кривым. Например, кривизну синего прямоугольника ниже было бы очень сложно определить программно.

Изучив, как другие разработчики решали подобные проблемы, я обнаружил элементы SVG <clipPath> и <mask>. Тогда я смог придумать элегантное решение моей проблемы. ClipPath может быть прикреплен к элементу, чтобы ограничить его видимую часть. Итак, я понял, что могу нарисовать еще одну копию серого фона прямо поверх него. Вновь скопированный прямоугольник будет соответствовать по форме серому фону, с той лишь разницей, что он будет синим. Затем, прикрепив clipPath к этому синему прямоугольнику, я мог ограничить видимую часть синего выделения в зависимости от положения лепестков.

В следующем фрагменте кода background - это <svg>, в котором уже нарисован серый фон. Я прикрепляю новый контейнер группы SVG (<g>) к background и связываю его с clipPath, у которого есть id = #selectionPath. Теперь видимая часть <g> будет определяться #selectionPath. Внутри <g> я рисую синий прямоугольник с закругленными углами, который соответствует серому фону. Затем я определяю #selectionPath и добавляю к нему <rect>. Ссылка на этот прямоугольник хранится в this.selectionClipPath. Изменение свойств this.selectionClipPath позволит нам управлять видимой областью синего слоя.

Теперь просто обновлять выделение выделения всякий раз, когда выбирается новое временное окно. Нам просто нужен способ получить обновленные позиции двух лопастей (windowStart и windowEnd). Затем атрибуты this.selectionClipPath можно изменить, чтобы выявить синий слой точно между windowStart и windowEnd.

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

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

Сначала я подумал, что лучший способ решить эту проблему - использовать элемент SVG <mask>. Маска обеспечивает поведение, противоположное clipPath; он определяет, какая часть элемента не отрисовывается. Итак, моей первой идеей было прикрепить маску к синему слою и по существу использовать ее для вырезания отверстий в слое, где будут рисоваться события. Это решение было бы совершенно правильным и почти идентично тому, которое я выбрал для реализации. Подход, к которому я в конечном итоге остановился, заключался в создании копии каждого события, окрашивании их всех в тот же оттенок серого, что и фон, а затем рисовании их поверх синего слоя, добавляя их к selectionBackground. Это дает тот же результат, что и при использовании <mask>; он эффективно прорезает дыры в синем слое везде, где есть события.

Имитация событий щелчка, перетаскивания и наведения в модульных тестах

Написание модульных тестов для поведения слайдера при щелчке, перетаскивании и наведении с помощью Jasmine оказалось серьезной проблемой. Это связано с тем, что это поведение контролируется обработчиками событий D3, которые прослушивают события мыши DOM. Я хотел найти способ запускать пользовательские события DOM в своих тестах, потому что это был бы наиболее строгий способ гарантировать, что все взаимодействия с пользователем работают должным образом. Спросив у товарищей по команде совета и прочитав многочисленные онлайн-дискуссии по этой теме, я узнал, что можно моделировать события щелчка и наведения на определенные координаты цели. Я экспериментировал с функцией dispatch в D3 и функцией node.dispatchEvent HTML-элемента и в конечном итоге нашел решение, которое лучше всего работает с ползунком. Он должен был создать MouseEvent объектов в пользовательских координатах, а затем вызвать dispatchEvent на узле <svg> ползунка, передав пользовательский MouseEvent.

В следующем коде показана вспомогательная функция, которую я написал для моделирования событий DOM в модульных тестах. Он принимает событие (mousedown, mouseup, mousemove или mouseout) и отправляет это событие на узелelem с координатами (clientX, clientY).

Затем я смог написать свои модульные тесты. В рамках тестов я использовал dispatch для моделирования желаемых событий, а затем проверил, что соответствующие элементы слайдера имеют правильные свойства. Например, чтобы проверить щелчок по ползунку, я бы запустил пользовательское событие mousedown, а затем проверял, переместилось ли весло на event.clientX и было ли время, отображаемое в нижней части весла, точным.

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

С dispatch я смог протестировать поведение ползунка при нажатии и наведении, но запуск пользовательских событий MouseEvents не подходил для имитации поведения перетаскивания. Это связано с тем, как D3 обнаруживает перетаскивание. Когда прослушиватель перетаскивания D3 прикреплен к элементу, он ожидает появления определенной последовательности MouseEvents для элемента, а затем запускает обработчик обратного вызова, связанный с этим прослушивателем. Я много раз пытался воспроизвести эту последовательность событий DOM - я считал, что это должно быть mousedown, несколько непрерывных движений мыши и, наконец, mouseup - но так и не смог успешно запустить слушателей перетаскивания. Единственный оставшийся вариант - обойти D3 и напрямую вызвать обработчики перетаскивания ползунка в моих тестах. Написанные таким образом тесты оставались надежными, пока слушатели перетаскивания D3 вели себя так, как задумано. Назначение слушателей перетаскивания D3 состояло только в том, чтобы определить, какой элемент перетаскивает пользователь (ракетка или другие области ползунка), и вызвать соответствующий обработчик перетаскивания (onPaddleDrag или onSliderDrag). Это означало, что вызов onPaddleDrag и onSliderDrag непосредственно в тестах по-прежнему точно имитировал бы результат взаимодействия человека с ползунком.

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

Определение пользовательских шаблонов hovercard

Ключевой особенностью ползунка шкалы времени является возможность разработчиками полностью настраивать стиль и содержимое подсказок. Это было сложно реализовать, потому что мы хотели, чтобы API для настройки hovercard легко интегрировался с Angular. Мы хотели позволить разработчикам передавать любой допустимый шаблон Angular и идеально отображать его на картах наведения. Это сделает разработчиков удобными и даст им большую гибкость; они просто писали бы стандартный Angular и имели бы полный доступ к его возможностям. Написание API, позволяющего добиться этого, было сложной задачей, но мой хост научил меня, как это сделать, используя расширенные функции Angular.

Ниже приводится очень простой пример того, как можно определить шаблон hovercard с помощью готового API.

Компонент ползунка временной шкалы имеет @Input, который позволяет ему принимать массив объектов данных событий. Привязка шаблона используется для создания нового невидимого TimelineEvent компонента для каждого объекта данных. Хотя все компоненты TimelineEvent невидимы, каждый из них по-прежнему содержит представление, созданное в соответствии с шаблоном, определенным в тегах <timeline-event>. В этом конкретном случае каждый компонент TimelineEvent содержит заголовок заголовка и абзац описания (см. <h1>{e.title}</h1> и <p>{e.description}</p> выше). Заголовок и описание меняются для каждого TimelineEvent компонента, потому что каждый раз, когда компонент создается для нового объекта данных, объект становится доступным для шаблона через структурную директиву *event="let e". Это можно сделать в Angular, установив контекст компонента для объекта данных, т.е.view.context.$implicit = currentEventDataObject. Чтобы узнать больше, вы можете посетить эту ссылку.

Решения UX

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

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

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

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

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