How did we do “narrative” - a new publication format in Yandex.Zen

    For two years, Yandex.Zen learned to solve the problem of personal content recommendations. Now Zen is not only an aggregator of articles and videos from third-party Internet resources, but also a content platform. In the summer of 2017, a publishers platform was launched, on which everyone can create publications, and when reaching 7000 searches, they can earn money on this. You can read about the monetization system and other platform features in the Zen magazine .

    Articles and videos are traditional types of content. To attract authors to the platform and give them new tools to increase the audience, Zen decided to go beyond the usual formats. One of the new formats has become a narrative. This is a set of cards united by a common theme. Internet users read less and less, but still want to get interesting stories (so they, for example, watch TV shows, short videos and live broadcasts). We created a format that helps authors tell consistent short stories and entertain readers.


    Narratives of publishers and authors

    A card can contain text, links, images, videos and GIFs. A narrative can tell a story, give step-by-step instructions or a recipe, publish a list of useful books, describe the advantages and disadvantages of budget management approaches. This is a format for authors who create interesting content but don’t write long texts.

    Basically, the format is focused on mobile phones, as often people consume information and entertainment content from mobile devices. We added restrictions: the format should be capacious, but short, so the number of narrative cards is not infinite. Each card contains a maximum of one video and one link, this allows you to conduct a narrative sequentially without overloading the reader's attention. An interested person can go from the narrative to the author’s website, read the extended version of the material, however, the content of the narrative should be enough to understand the topic.


    Example: narrative narrative

    The closest narrative analogue, stories on Instagram, is limited in time and only shown for 24 hours. This affects the content: the materials may not be related by a common topic, uninformative, oriented towards social interaction and receiving reactions from familiar people. Despite the fact that we like stories, this format does not suit Zen. Our publications appear much longer and are recommended to an audience, often not belonging to the same social or geographical group. We built a format that combines the ease of microformat with the involvement and storyline of long reads.

    To give users the opportunity to create diverse and unique narratives, we needed to provide them with a special editor with tools for layout and design of content - like programs for creating presentations. The editor was supposed to prompt the authors how to make the presentation visually attractive, report format limitations, and be easy to use. Therefore, we added a view mode where the narrative is presented as it will be seen by readers. We did not limit the authors to templates for the arrangement of elements: all elements on the narrative card can be arranged arbitrarily. In addition, we have developed a layer system that allows you to control the overlapping of elements on each other.


    Narrative editor

    In the process of creating the editor, we encountered a number of interesting technical problems. This article is about how we solved them.

    Stack used


    The technology base consisted of React (for the editor), preact (for display), Redux, Draft.js (for text blocks) and flowtype. The state is stored in a normalized form (see normalizr), which made it possible to quickly perform the most common operation - updating the properties of elements on the card. For other actions (such as swapping cards, adding and removing blocks, etc.), a normalized state also shows better performance than normal data storage in the form of an object tree.

    Making the card and blocks on the card adaptive


    The first task was to create an adaptive card that preserves the composition at any size. On mobile platforms, the card seeks to occupy the maximum area, taking into account the aspect ratio, so its dimensions can vary greatly from device to device.

    Consequently:

    • The card saves the specified aspect ratio (we chose 44:75) at any screen size.
    • The text on the card also retains the same relative footprint for any card size. That is, the font size should be proportional to the size of the card.
    • Blocks preserve relative sizes and locations for any card size.

    Consider ways to implement these requirements.

    How to keep the aspect ratio of the card?


    First I wanted to use pure CSS. Indeed, the network describes several methods that allow you to do this:

    • Dimensioning via padding using the padding property, set as a percentage, since the relative value of the block width is taken. This method did not fit, since it does not allow you to "enter" the card into the screen. In other words, if the height of the card exceeds the height of the screen, the card will not decrease in height, maintaining its proportions.
    • Sizing through the combination of height, width, max-height and max-width specified in vh and vw allows you to achieve the desired effect. However, the method is limited by the screen size. In other words, when embedding cards in a layout where the card does not occupy the whole screen (for example, in the editor), the aspect ratio will not be saved.

    Thus, a pure CSS solution had to be abandoned, and as a result, a JS solution was used, which turned out to be much more compact and understandable than a CSS solution:

    // @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)),
        };
    }
    

    It does not give a tangible minus in rendering speed, there is a potential for acceleration. For example, you can remove the alignment from the main JS bundle and execute it immediately after the HTML code of the cards. Then the cards will immediately display in the correct sizes.


    Card proportions are saved on any screen

    How to save the relative sizes of text elements?


    To proportionally resize text elements inside the slide, we did the following:

    1. All sizes in text elements are given in em.
    2. For a slide, the font size is set in px and calculated in the proportion obtained from the following positions:

      • Let the base slide width (BASE_WIDTH) be 320px.
      • Let the basic font size (BASE_FONT_SIZE) be 16px for the base width of the slide.
      • Then, when changing the size of the slide, the new font size is calculated as follows:

        const relativeFontSize = (BASE_FONT_SIZE * slideSize.width) / BASE_WIDTH;

    Thus, setting the font size in em automatically recalculates the font size of the elements.

    How to make objects on the card keep location and relative sizes?


    To preserve the composition, the introduction of a relative coordinate system is best suited. Thanks to the web platform, such a system already exists - this is the job of the size and location of the blocks in percent! Indeed, whatever the size of the card in pixels, the size and location of objects, set as a percentage, allow them to change proportionally.

    It turns out that we introduced a new coordinate system (“card”) within each card with a visible area from 0 to 100% on each axis. Now we need to learn how to convert all pixel sizes to percentage. This will be needed when we will:

    • Read the sizes of objects, based on knowledge of what are the dimensions on one axis, and the original dimensions. For example, if we insert an image on a slide, set the default 90 percent width and should calculate the height in percent.
    • Move objects on the card.
    • Change their size.

    Initializing objects with unknown sizes


    Now, having a “card” coordinate system, you can place blocks on a card without worrying that their relative position will be distorted when the card is resized.

    Each block has a geometry property that describes the size and location of the block:

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

    If you add a block with a fixed aspect ratio (for example, a picture or video), there is a problem of recalculating the sizes from the pixel coordinate system to the "card".

    For example, when adding a picture to a slide, the default is set to 90 percent width of the element in the "card" coordinate system. Knowing the original dimensions of the image (Image.naturalWidth and Image.naturalHeight), the dimensions of the "card pixel" and the width of the image in new coordinates, it is necessary to calculate the height (also in new coordinates). Having resorted to the knowledge of higher arithmetic, we deduced the calculation function in the "card" coordinate system. For example, you can calculate the height of the picture:

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

    Here natural is the image size in px, container is the slide size in px, relativeWidth is the size of the image in the percentage.



    Object Movement


    When we mastered translations into the “card” coordinate system, it became easy to realize the movement of the object. The code responsible for this is something like this:

    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 point resizing


    In any decent visual editor, you can resize the object by dragging the "squares" located at the corners of its borders. We also needed to realize this opportunity.



    Writing a compact and understandable code that handles resizing an object depending on the angle the user is pulling at was not so simple. Before “cycling” our decision, we reviewed how this is done in popular libraries. For example, the code in jQuery UI



    looks like this : The code looks compact, but it’s not easy to figure it out: functions are not “clean”, a large number of internal methods of the class and its properties are used, the context of the function execution matters (see apply).

    In our project, approximately the same code is writtenas follows . Here, the minimum size of the object and the optional restriction on maintaining the aspect ratio (preserveAspectRatio) are additionally taken into account - this is important when changing the size of a video or picture.

    Our code cannot be called compact, but the function turned out to be “clean”, and the structure of the solution itself was straightforward.

    It would be great if you, dear readers, proposed a version of the code that solves this problem. I admit that there is a certain pattern, after understanding which the code becomes super short and understandable.

    The problem of inconsistent text rendering on different platforms


    After the more or less large-scale testing of the display of the narrative began, we were surprised to find that in some cases the same text with the same font, size and other attributes has a different number of lines on different platforms!

    For example, in Safari, when creating a narrative, some text block had 4 lines, but when viewing it in Chrome on Android, 3 lines were shown. We still did not find out the exact reason for this behavior and attributed it to the features of text rendering engines on various platforms.

    We solved the problem by breaking text blocks into lines before publication. And here, too, there was a place of interest. The first approach to defining strings was to wrap each character inand determining its position using getBoundingClientRect. This worked quickly, and for quite some time we did not notice the problem that this approach gave rise to. Guess what the issue is?

    It turned out that many fonts, including Yandex Sans Text , contain optimizations for displaying the intersymbol distance for some character combinations (kerning).


    The CSS column property font-kerning: none is set in the right column

    If you wrap each character in , this optimization does not work! It turns out that a string with the indicated combinations, but without tags around each character (that is, the one that the editor user sees it) may be shorter than with tags.

    You can quickly solve this problem by the ancient CSS-property font-kerning: none, which simply disables these optimizations. Most likely, most people viewing the narrative will not notice anything.

    But there must be a way to make everything beautiful! And we found a solution in using the same ancient, but very useful Range API, which can provide information similar to getBoundingClientRect () for a given range of text selection. We are currently working on this solution, and we hope that in the near future it will go to production.

    Difficult underlay under text elements


    Many authors used translucent images to increase the contrast of the font placed on top of the photo. Others wrote to us asking them to add the corresponding function to the editor itself.

    Our designer, Anya, surprised the development with the choice of the most difficult version of the geometry of the substrate. In addition to combining lines of similar length into one rectangle, the idea came up to use the middle of a lowercase letter without extra elements (for example, “a” or “o”) as the axis of symmetry. Such an implementation creates an enhanced effect of "cartoon" of the resulting figures - they resemble speech bubbles in comics.


    Algorithm and implementation of the substrate

    It was necessary to draw the figures manually, using the dimensions of the lines calculated at the last stage. They are implemented as closed svg paths, consisting of arcs of circles of the same radius and straight lines.

    Since none of the known technologies was suitable for solving the problem, we wrote our own algorithm for rendering the svg curve, which is used for the cover.

    Conclusion


    Narrative is a new format, and it needs to be developed. For better involvement in the story, we will increase the area of ​​the narrative card, add graphic elements and animations, support the use of gestures and the possibility of a “seamless” continuation of viewing similar narratives.

    Readers appreciate the quality of publications. To make publications better, we’ll be telling authors what the audience likes. Some authors have already shared their observations and ways to create good narratives.

    From a technical point of view, there were unresolved problems and scope for optimization. For example, in some built-in browsers on Android (usually in browsers from the vendor itself), when the system font is increased, the font size on the web page is forced to not lower than a certain threshold. In the case of a narrative, this of course breaks the composition.

    The native implementation of the narrator viewer on iOS and Android is planned, so we are exploring the possibility of simplifying the creation of such viewers. It seems to us that one of the interesting ways is “screenshots” of individual elements on a slide. They would allow us not to think about the correct font size: pictures, unlike text, very naturally change in size due to the percentage “card” coordinate system. In addition, we don’t need to download the Yandex font at all, we don’t have to pull a rather tricky algorithm for rendering the text substrate, etc.

    Finally, we plan to transfer the video from streams (initially there was a good infrastructure for streaming video) to ordinary MP4 / WebM files: with short videos, this approach shows better compatibility and speed.


    The article was prepared by Yandex.Zen staff: Dmitry Dushkin and Vasily Gorbunov wrote about the frontend, Ulyana Salo - about design.

    Also popular now: