Привет, меня зовут Артём, я руководитель одной из групп разработки интерфейсов в Яндексе. Неделю назад на Я.Субботнике я рассказал, как мы использовали SVG для создания внутреннего календаря. Это расшифровка моего доклада, несколько историй из реализации виджета календаря: масштабирование, заливка паттерном, маски, символы и особенности формата.
— В Яндексе работает много народу, все в разных городах, в разных часовых поясах, и нужно понимать, когда твои коллеги заняты, а когда ты можешь с ними встретиться и поговорить. Мы решили спроектировать календарь, который поможет это узнать.
Начали мы, конечно, с макета. Он выглядел так:
На нем видно четные и нечетные события разной заливкой. События, которые перекрывают другие события, то есть находятся на втором слое — другой заливкой. События, которые занимают весь день, закрашивают весь день. Текущее время отображается внизу. Такой была цель.
Мы начали выбирать, на чем же мы будем его делать. Сделали несколько разных прототипов. Начали с canvas, но там надо было много кода, масштабирование вручную писать. У нас была идея, что календарик занимает столько места, сколько нужно, в разных лэйаутах он разной формы и разного размера. А для сanvas это было сложновато.
Был прикольный прототип, когда мы генерировали всю эту картинку линейными градиентами, но она при масштабировании и при переходе на ретину съезжала. Поэтому в итоге мы пришли к SVG. Почему? Во-первых, там полностью независимая от документа система координат, поэтому можно внутри расположить всё абсолютно, и это никак не сломается независимо ни от чего. Также там есть нормальная работа с масштабированием. Даже если в браузере сделать зум, если открыть на ретине или как угодно растягивать календарик, он будет ресайзиться как картинка и в любом случае выглядеть нормально. У нас на макете была заливка клеточками, и очень хорошо, что в SVG есть заливка паттернами.
Чтобы нарисовать календарик, нужны некоторые данные. Чтобы нарисовать тот, который на макете, нужно знать, с какой даты он начинается — обычно с текущей, — знать, сколько дней должно быть по горизонтали, сколько часов отображать по вертикали и с какого часа начинается день в календаре. Надо каким-то образом получить события.
Так как у нас много офисов в разных часовых поясах, мы решили, что события всегда будут приходить в UTC, а мы их уже будем на клиенте отображать для пользователей, так как есть необходимость посмотреть на календарик для своего часового пояса и для часового пояса человека, на чей календарик ты смотришь — чтобы понять, что у него сейчас ночь, и лучше встречу не назначать. То, что красным подсвечено, будет использоваться потом.
Начнем с основы. SVG — гигантская координатная плоскость, на которой можно произвольно размещать векторную графику. При этом часть области, которую мы видим, определяет viewBox, а что за ее границами — это такой overflow hidden на стероидах. Что бы там ни было, его не будет видно. Мы решили, что для простоты расчетов сделаем в календаре один пиксель, равный одной минуте. Поэтому один час будет занимать ровно 60 пикселей. Чтобы было еще проще, мы решили, что день по ширине тоже будет 60 пикселей — чтобы все было квадратным, как в армии. И начали верстать.
Viewbox задается четырьмя параметрами. Первые два — верхняя левая точка в системе координат, от которой считается viewBox, для нас это 0,0. При этом ширина — это 60 * на количество дней, а высота — 60 * на количество часов.
Внутрь SVG валидно вставлять другие документы SVG, в которых внутри будет своя система координат. И чтобы события в дне можно было позиционировать только по вертикальной оси, мы решили, что на каждый день заведем отдельный SVG, и их просто сместим по горизонтали на 60 * на позицию дня в календаре. Тогда все события можно будет просто по вертикали по Y ставить, будет очень удобно. А внутрь каждой SVG, которая представляет собой день, мы положили прямоугольник, который будет отображать заливку дня.
Этот прямоугольник, так как не указан цвет заливки, будет наследовать свойство fill от SVG. В данном случае этот день рабочий, и два дня в неделю выходные, поэтому они светленьким залиты. Это как раз определяется классами.
Заготовка есть. Теперь надо добавить сетку. Так как мы хотели ресайзить календарь, а линии сетки должны быть всегда однопиксельными, мы использовали атрибут vector-effect=non-scaling-stroke. Это приводит к тому, что как бы мы ни ресайзили, ни зумили, всегда будет однопиксельная сетка. Достаточно просто горизонтальных и вертикальных линий нужное количество добавить, и будет такая сетка.
С основой разобрались, перейдем к событиям на весь день. Это такая хитрая штука. Вы замечали, что в календарях есть события и есть галочка «на весь день». Эти события отличаются тем, что они идут весь день, независимо от того, в каком часовом поясе вы на них смотрите. Поэтому если где-нибудь в самом начале часовых поясов на Аляске событие начинается рано утром, то где-то через 48 часов в противоположном конце земного шара оно все еще будет идти. Звучит сложно, но для реализации это проще всего: просто сравниваешь дату с датой отображаемого дня. Если попадает — значит событие в этот день. Если два события на весь день попадают на день, то показывается то, которое позже началось. Так заливкой отображаются события на весь день.
С остальными событиями несколько сложней. Есть, допустим, встреча. Она синей заливкой, все просто. Однако, если две встречи идут подряд, согласно нашему макету, мы заливаем их разным цветом, они четные и нечетные.
Если одна встреча пересекается с другой, лежит выше, надо как-то это отображать. Если есть со встречами пересекания, то они заливаются совсем отдельно, клеточками. И чтобы нам было еще веселее, у нас есть не только встречи, но и отсутствия, конференции и много всякого такого. Не хотелось все это хардкодить в вёрстке, поэтому мы решили придумать, как это более-менее кроссбраузерно и удобно в CSS настраивать.
Сейчас будет самый сложный пример со всего доклада, наберитесь терпения и следите, будет три шага, потом станет полегче.
Начнем по порядку. В SVG есть тег <defs>, он позволяет объявлять внутри него элементы, которые не отображаются, но их можно по ссылке использовать, ссылаясь на них. Первое, что мы сделаем — объявим <defs>, и в нем заведем паттерн. <pattern> — это тег, который позволяет объявить паттерн, который можно использовать для заливки того или иного элемента тем или иным узором.
Нам надо сделать в этом паттерне клеточки. У нас 60 на 60 пикселей, клеточки должны быть 6 на 6, поэтому мы объявили паттерн 12 на 12, и внутри него нарисуем <path>, как на схеме слева. У него есть атрибут d, который обозначает, как именно двигается линия. Он начинается из точки 0,0, и потом по координатам стрелками показано, как именно он рисуется. Если мы зальем его белым, получится такой узор: что не залито белым, залито черным.
Переходим к следующему шагу, теперь объявим маску. <mask> — это такой элемент в SVG, который позволяет добавлять другим элементам альфа-канал. То, что в маске нарисовано черным, в том элементе, к которому маска применена, невидимо, прозрачно. То, что нарисовано белым, непрозрачно. То, что серым, то полупрозрачно. У нас черно-белый паттерн, и мы внутрь маски добавим прямоугольник, и его этим паттерном зальем. Теперь у нас есть маска.
Следующий шаг — <symbol>. Это такой тег в SVG, который позволяет объявлять переиспользуемую графику. Чаще всего символы используются, например, для иконок. И здесь мы объявим символ, внутрь которого положим два прямоугольника. Один ничем не зальем, чтобы он наследовал свойство fill от родительского SVG, а другой зальем currentСolor и применим к нему маску. Теперь у нас будет два прямоугольника: один с дырками и залит currentColor, а другой без дырок и залит fill. И они друг на друге лежат. Если мы зададим эти цвета одинаковыми, у нас будет сплошная заливка. А если разными — клеточки. К этому всё и шло. Теперь можно просто использовать CSS и через классы задавать произвольную заливку двух цветов для всех событий.
Теперь надо определить, какие именно события должны попасть в календарь в тот или иной день. У нас есть часовой пояс +3, в котором мы все сидим, в нем есть шкала от 9 до 20 часов. Также есть человек, который сидит в условном Оренбурге, у него часовой пояс +5, его шкала смещена относительно нас на два часа. Мы сделаем проекцию на UTC, и видим, что по UTC этот промежуток от верха до низа надо отобразить в дне, чтобы пользователь мог, переключаясь между часовыми поясами, видеть и события, которые попадают в его календарь, и календарь того, на кого он смотрит.
Запомним эти числа, которые лежат в offset, потому что проще всего события, которые приезжают в UTC, позиционировать в этом же самом UTC. Для этого мы возьмем тег <g>, который обозначает в SVG группу, и все события там спозиционируем абсолютно по UTC, а сам <g> будем смещать на нужное нам количество пикселей, чтобы отображался тот или иной часовой пояс.
Подытожив это исследование, мы получаем, что у нас есть символ, на который мы ссылаемся, есть тип события, уровень, четность, есть его -120 минут от начала дня в UTC и длительность 30 минут. Добавив все события, мы получим такую картинку.
Текущее время тоже делается просто, это будет линия с тем же эффектом non-scaling-stroke, чтобы она всегда была однопиксельной. Вот как она отображается.
Время на месте не стоит, и надо, чтобы стрелочка двигалась. Самый прикольный способ, который мы придумали, — анимация. Мы решили, что сделаем анимацию, которая будет смещать стрелочку на количество минут в сутках, и делать это за сутки. А чтобы она не постоянно медленно двигалась, а именно тикала раз в минуту, мы использовали steps(). И как только мы это добавили, время стало двигаться. При этом на самом деле, так как анимация не гарантирует, что будет постоянно двигаться, она то отстает, то еще что-то. Но у нас события в календаре время от времени обновляются, и где-то раз в две-три минуты или когда пользователь ушел со вкладки и вернулся, весь календарь перерисовывается и время обновляется. Поэтому анимация видна, только когда ты сидишь и пристально смотришь, тикает она или нет.
Есть одна проблема. Здесь я сделал календарик пошире, чтобы он больше был похож на тот, что в продакшене. Стало видно, что клеточки уже не квадратные. Это потому что пропорции не сохраняются, и если мы растягиваем или изменяем соотношения сторон физически, то изменяется оно как в картинке. Чтобы этого избежать, надо написать немного JS. Есть соотношение сторон viewBox, которое было в нашем изначальном SVG, и фактическое соотношение сторон, которое используется у нас в верстке. Если найти отношение этих соотношений и потом его засунуть в трасформ паттерна, то клеточки станут квадратными. А еще этот коэффициент, который мы тут получили, можно использовать, если мы хотим понять, куда кликнул пользователь. Так как у нас одна минута в исходном SVG равна одному пикселю, то по координатам клика, умноженного на этот коэффициент, можно понять, в какое время попал пользователь.
Осталось добавить HTML, чтобы были буквы и цифры сверху. Получится календарик.
Так эта штука выглядит в продакшене глазами пользователя, который сидит в часовом поясе +5. Cнизу есть тумблер, который мой коллега нажимает, и календарик двигается по часовым поясам. Потом он кликает на событие и видит, что в субботу в часовом поясе +5, то есть прямо сейчас, идет мой доклад.
Еще немного примеров. Вот календарь разработчика, у него есть стендапы, несколько регулярных встреч и всё. Вот календарь менеджера. А вот — дизайнера.
Пользуйтесь CSS, пользуйтесь SVG. Спасибо!
Автор: Артём