Главная » Хабрахабр » Как мы делали «нарратив» – новый формат публикаций в Яндекс.Дзене

Как мы делали «нарратив» – новый формат публикаций в Яндекс.Дзене

Два года Яндекс.Дзен учился решать задачу персональных рекомендаций контента. Теперь Дзен — не только агрегатор статей и видео со сторонних ресурсов интернета, но и контент-площадка. Летом 2017 года была запущена платформа издателей, на которой каждый может создавать публикации, а при достижении 7000 досмотров — зарабатывать на этом деньги. Про систему монетизации и другие особенности платформы вы можете прочитать в журнале Дзена.

Статьи и видео — традиционные виды контента. Чтобы привлечь авторов на платформу и дать им новые инструменты увеличения аудитории, Дзен решил выйти за рамки привычных форматов. Одним из новых форматов стал нарратив. Это набор карточек, объединенных общей тематикой. Пользователи интернета все меньше читают, но все так же хотят получать интересные истории (поэтому они, например, смотрят сериалы, короткие видео и живые трансляции). Мы создали формат, который помогает авторам рассказывать последовательные короткие истории и развлекать читателей.

Нарративы издателей и авторов

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

Пример: нарратив о нарративе

Самый близкий аналог нарративов, stories в Instagram, ограничены по времени и показываются только 24 часа. Это влияет на содержание: материалы могут быть не связаны общей темой, малоинформативны, ориентированы на социальное взаимодействие и получение реакции от знакомых людей. Несмотря на то, что нам нравятся stories, такой формат не подходит Дзену. У нас публикации показываются намного дольше и рекомендуются аудитории, часто не принадлежащей к одной социальной или географической группе. Мы строили формат, который объединяет в себе легкость микроформата с вовлеченностью и сюжетностью лонгридов.

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

Редактор нарративов

В процессе создания редактора мы столкнулись с рядом интересных технических задач. Эта статья — о том, как мы их решали.

Используемый стек

Технологическая база состояла из React (для редактора), preact (для показа), Redux, Draft.js (для текстовых блоков) и flowtype. Состояние хранится в нормализованном виде (см. normalizr), что позволило быстро производить самую частую операцию — обновление свойств элементов на карточке. Для других действий (таких как перестановка карточек, добавление и удаление блоков и пр.) нормализованное состояние также показывает более высокую производительность, чем обычное хранение данных в виде дерева объектов.

Делаем карточку и блоки на карточке адаптивными

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

Следовательно:

  • Карточка сохраняет заданное соотношение сторон (мы выбрали 44:75) при любом размере экрана.
  • Текст на карточке тоже сохраняет одинаковую относительную занимаемую площадь при любом размере карточки. То есть размер шрифта должен быть пропорционален размеру карточки.
  • Блоки сохраняют относительные размеры и расположение при любом размере карточки.

Рассмотрим способы реализации перечисленных требований.

Как сохранить соотношение сторон карточки?

Сначала захотелось воспользоваться чистым CSS. И действительно, в сети описывается несколько способов, которые позволяют это сделать:

  • Задание размеров через padding с использованием свойства padding, заданного в процентах, поскольку берется относительное значение ширины блока. Этот способ не подошел, так как не позволяет «вписывать» карточку в экран. Другими словами, если высота карточки превысит высоту экрана, карточка не будет уменьшаться по высоте, сохраняя свои пропорции.
  • Задание размеров за счет комбинации height, width, max-height и max-width, заданных в vh и vw, позволяет добиться желаемого эффекта. Однако способ ограничен размерами экрана. Иначе говоря, при встраивании карточек в верстку, где карточка займет не целый экран (например, в редакторе), соотношение сторон не будет сохраняться.

Таким образом, от решения на чистом CSS пришлось отказаться, и в итоге используется решение на JS, которое оказалось куда компактнее и понятнее решения на CSS:

// @flow
type Size = { width: number, height: number }; function getFittedSlideSize(container: Size, target: Size): Size { const targetAspectRatio = target.width / target.height; const containerAspectRatio = container.width / container.height; // if aspect ratio of target is "wider" then target's aspect ratio const fit = targetAspectRatio > containerAspectRatio ? 'width' // fit by width, so target's width = container's width : 'height'; // fit by height, so target's height = container's height return { width: fit === 'width' ? containerWidth : Math.round(containerHeight * ( target.width / target.height)), height: fit === 'height' ? containerHeight : Math.round(containerWidth * (target.height / target.width)), };
}

Ощутимого минуса в скорости отрисовки оно не дает, есть потенциал к ускорению. Например, можно вынести выполнение выравнивания из основного бандла JS и выполнять его сразу после HTML-кода карточек. Тогда карточки будут сразу отображаться в правильных размерах.

Пропорции карточки сохраняются на любом экране

Как сохранить относительные размеры текстовых элементов?

Для пропорционального изменения размера текстовых элементов внутри слайда мы сделали следующее:

  1. Все размеры в текстовых элементах заданы в em.
  2. Для слайда размер шрифта задается в px и вычисляется в пропорции, полученной из следующих положений:

Тем самым задание размера шрифта в em приводит к автоматическому перерасчету размера шрифта элементов.

Как заставить объекты на карточке сохранять расположение и относительные размеры?

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

Получается, мы ввели новую систему координат («карточную») в рамках каждой карточки с видимой областью от 0 до 100% по каждой из осей. Теперь надо научиться пересчитывать все пиксельные размеры в процентные. Это понадобится, когда мы будем:

  • Считать размеры объектов, исходя из знаний о том, каковы размеры по одной оси, и исходных размеров. Например, если мы вставляем изображение на слайд, задаем 90-процентную ширину по умолчанию и должны посчитать высоту в процентах.
  • Перемещать объекты по карточке.
  • Менять их размеры.

Инициализация объектов с неизвестными размерами

Теперь, имея «карточную» систему координат, можно размещать блоки на карточке, не переживая, что их взаимное расположение исказится при изменении размера карточки.

Каждый блок имеет свойство geometry, которое описывает размеры и расположение блока:

{ geometry: { x: number, y: number, width: number, height?: number }
}

Если добавить блок с фиксированным соотношением сторон (например, картинку или видео), возникает проблема перерасчета размеров из пиксельной системы координат в «карточную».

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

function getRelativeHeight(natural: Size, container: Size, relativeWidth: number) { return (natural.height / natural.width) * (container.width / container.height) * relativeWidth;
}

Тут natural — размеры картинки в px, container — размеры слайда в px, relativeWidth — размеры картинки на слайде в процентах.

Передвижение объектов

Когда мы освоили переводы в «карточную» систему координат, реализовать передвижение объекта стало несложно. Код, который за это отвечает, примерно такой:

type Size = {width: number, height: number};
type Position = {x: number, y: number}; class NarrativeEditorElement extends React.Component { // ... handleUpdatePosition = (e) => { // slide - DOM-элемент, который вмещает текущий элемент const {slide} = this.props; if (!this.state.isMoving) { // this.ref — DOM-элемент текущего объекта (текста, картинки и т.п.) this.initialOffsetLeft = this.ref.offsetLeft; this.initialOffsetTop = this.ref.offsetTop; } const relativePosition = getRelative( {width: slide.offsetWidth, height: slide.offsetHeight}, {x: this.initialOffsetLeft + e.deltaX, y: this.initialOffsetTop + e.deltaY}, ); this.setState({ geometry: { ...this.state.geometry, x: relativePosition.x, y: relativePosition.y, }, isMoving: true, }); } // ...
} function getRelative(slideSize: Size, position: Position) { return { x: 100 * position.x / slideSize.width, y: 100 * position.y / slideSize.height, };
}

Изменение размеров по 4 точкам

В любом порядочном визуальном редакторе можно менять размеры объекта, перетаскивая «квадратики», расположенные по углам его границ. У нас такую возможность тоже нужно было реализовать.

Написать компактный и понятный код, обрабатывающий изменение размеров объекта в зависимости от угла, за который тянет пользователь, оказалось не так уж просто. Прежде чем «велосипедить» свое решение, мы провели обзор того, как это делается в популярных библиотеках. Например, так выглядит код в jQuery UI:

Код выглядит компактным, но разобраться в нем непросто: функции не являются «чистыми», используется большое количество внутренних методов класса и его свойств, имеет значение контекст выполнения функции (см. apply).

В нашем проекте примерно такой же код написан следующим образом. Тут дополнительно учитывается минимальный размер объекта и опциональное ограничение на сохранение соотношения сторон (preserveAspectRatio) — это важно при изменении размеров видео или картинки.

Наш код нельзя назвать компактным, но функция получилась «чистой», а структура самого решения — прямолинейной.

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

Проблема неконсистентного рендеринга текста на разных платформах

После того, как началось более-менее масштабное тестирование показа нарратива, мы с удивлением обнаружили: в некоторых случаях один и тот же текст при одном и том же шрифте, размере и прочих атрибутах имеет разное число строк на разных платформах!

Например, в Safari при создании нарратива какой-то текстовый блок имел 4 строки, однако при просмотре его в Chrome на Android показывались 3 строки. Мы так и не выяснили точную причину такого поведения и списали его на особенности движков рендеринга текста на различных платформах.

Проблему мы решили разбиением текстовых блоков на строки перед публикацией. И тут тоже нашлось место интересному. Первый подход к определению строк состоял в оборачивании каждого символа в <span> и определении его положения с помощью getBoundingClientRect. Это работало быстро, и мы довольно долго не замечали проблему, которую породил такой подход. Догадаетесь, о какой проблеме идет речь?

Оказалось, многие шрифты, в том числе и Yandex Sans Text, содержат оптимизации отображения межсимвольного расстояния для некоторых сочетаний символов (кернинг).

В правом столбце выставлено CSS-свойство font-kerning: none

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

Быстро решить эту проблему можно древним CSS-свойством font-kerning: none, которое попросту отключает эти оптимизации. Скорее всего, большинство людей, просматривающих нарратив, ничего не заметят.

Но ведь должен же быть способ сделать всё красиво! И мы нашли решение в использовании такого же древнего, но весьма полезного Range API, который может для заданного диапазона выделения текста предоставлять информацию, аналогичную getBoundingClientRect(). Сейчас мы работаем над этим решением, и, надеемся, в ближайшее время оно поедет в продакшен.

Непростая подложка под текстовыми элементами

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

Наш дизайнер, Аня, удивила разработку выбором самого непростого варианта геометрии подложки. Помимо объединения строк похожей длины в один прямоугольник, появилась идея использовать середину строчной буквы без выносных элементов (например, «a» или «o») как ось симметрии. Такая реализация создает усиленный эффект «мультяшности» получившихся фигур — они напоминают speech bubbles в комиксах.

Алгоритм и реализация подложки

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

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

Заключение

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

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

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

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

Наконец, планируем переводить видео с потоков (изначально для потокового видео была хорошая инфраструктура) на обычные файлы MP4/WebM: с короткими видео такой подход показывает лучшую совместимость и скорость работы.


Статья подготовлена сотрудниками Яндекс.Дзена: Дмитрий Душкин и Василий Горбунов написали про фронтенд, Ульяна Сало — про дизайн.


x

Ещё Hi-Tech Интересное!

Кто есть что в рекрутменте Северной Америки — Часть 1

Спасибо Loriowar за приглашение на Хабр. Я уже давно почитываю, но никогда не писала. Я сама IT рекрутер из Канады, и мне очень прикольно читать, как программеры воспринимают процесс рекрутмента и hr-ов. Я сама когда-то пыталась стать программистом, прошла весь ...

[Перевод] Визуализация данных при помощи Angular и D3

D3.js — это JavaScript библотека для манипулирования документами на основе входных данных. Angular — фреймворк, который может похвастаться высокой производительностью привязки данных. От симуляций D3 до SVG-инъекций и использования синтаксиса шаблонизатора. Ниже я рассмотрю один хороший подход по использованию всей ...