[Прошедшему Году литературы посвящается]


Это была очередная пятница в тихом, уютном баре с лучшими друзьями… Разговор шел как обычно: новости, работа, шутки и опять по кругу. В поисках темы для разговора, потягивая из пивных кружек, почему-то вспомнили о стихах :) И тут каждый стал припоминать, что он еще помнит с тех далеких школьных лет. Если спотыкался, остальные подсказывали, ежели кто помнил, было довольно весело и интересно. Возвращаясь домой в тот вечер, я подумал: а что если сделать простое веб-приложение, чтобы каждый мог вспомнить эти прекрасные произведения русской поэтической мысли? Дизайн приложения уже крутился в голове, и я засел за разработку…




Постановка задачи


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


Дополнительные требования к приложению:


  • простота и понятность
  • отзывчивый дизайн и доступность на мобильных устройствах
  • плавная анимация и качество подачи и работы приложения
  • отображение подсказок пользователю, если он не помнит пропущенного слова
  • поддержка мультиязычности и разного набора стихов/авторов для разных стран
  • сделать полностью статическое приложение, чтобы можно было разместить на GitHub Pages и не платить за хостинг :P

Дизайн


Для оформления специально были проштудированы старые издания стихов, чтобы почерпнуть стилистику оформления печатных изданий того времени. Хотелось чего-то печатного, ощущения "бумаги" и при этом простого. Нынешнее увлечение Flat-дизайном делает вещи проще, тем более для программиста, ведь он не дизайнер. Имхо, получилось сносно:




Хорошему проекту нужно хорошее имя. Проверив несколько доменов, я быстро нашел подходящее имя для проекта — literator.io. Это только потом я узнал, сколько стоит домен в зоне .io, но менять что-то уже было поздно, "искусство требует жертв".


Разработка


Что может радовать программиста в работе больше, чем возможность писать хороший код и не быть связанным дедлайнами?


Но порядок все равно нужен, и я замутил небольшой Kanban в Trello. Люблю порядок.



Да, заметил, что и на GitHub’е теперь можно создавать борды. Возможно, перееду туда.


Технологии

Выбор фреймворка для построения приложения пал на AngularJS, т.к. несколько проектов с основного места работы использовали его. Дело было год назад, Angular 2 тогда был еще в глубокой жопе бете, а React был мне незнаком. Так же хотелось отточить свои навыки в AngularJS, чтобы не потерять сноровку, т.к. по работе приходилось больше писать на ванильном JS под Titanium SDK, а это совсем другая область.


Данные и как их хранить

Поразмыслив над возможной архитектурой и прикинув различные варианты, я пришел к следующей структуре:




Как видите, выделяются две сущности: авторы и стихи. Каждая из сущностей описывается метаданными, файлы которых располагаются в тех же директориях. Стихи (verses) дополнительно имеют файл content.txt, где как раз и хранится стих.


Отдельно выделяется файл structure.json. Если помните, одно из условий задачи было сделать приложение, для которого не нужен будет бекенд. А раз нет бекенда, то мы не можем перебирать доступные директории, чтобы узнать структуру наших данных. Как раз для этого и нужен файл structure.json, который хранит всю структуру и метаданные. Чтобы каждый раз не изменять этот файл вручную, была написана Node-утилита, которая проходится по всем доступным директориям и собирает метаданные (не зря же мы их там разложили, к тому же это удобно).


Как говорится: "Разделяй и властвуй". Хороший разработчик не будет хранить данные (хоть и статические) вместе с исходным кодом в том же репозитории, поэтому для стихов был создан отдельный репозиторий. Так же это позволит использовать всю мощь pull-реквестов для того, чтобы желающие могли добавить новые стихи и новых авторов. Данный репозиторий подключается к основному репозиторию через Git Submodules, что дает дополнительный контроль за тем, какая ревизия данных сейчас используется.


Оставалось изменить таск grunt build, чтобы всё собиралось и копировалось по своим местам.


Стихи и автодополнение слов

Отдельно хотел остановиться на том, как было реализовано автодополнение слов и вообще разбитие стиха на фрагменты, которые нужно заполнить пользователю. Стоял выбор между алгоритмическим выбором блоков и явным указанием этих блоков в самом стихе. Еще хотелось сделать возможность выбора сложности в будущем, поэтому блоки должны были выбираться по-разному. Я выбрал второе — явное указание этих блоков в самом стихе. Это требует предподготовки текста стиха, но позволяет получить лучший результат, т.к. позволяет подобрать [субъективно] наиболее удачные фрагменты, которые будут соответствовать рифме/течению стихотворения и смыслу повествования. Так же тот, кто подготавливает стих, может заранее оценить на сколько сложно/просто будет решить конкретный фрагмент.


?Шли {годы{}}. Бурь порыв {мятежный}?
Рассеял прежние {мечты},
?И я забыл твой {голос} нежный,?
Твои небесные {черты}.

Как видите, фрагменты заключены в фигурные скобки. Вложенность необходима для указания фрагментов разной сложности. Скобки "раскрываются" от внутренних к наружным, таким образом "легкому" уровню сложности соответствуют самые внутренние скобки в конкретном выделенном фрагменте. Фрагмент вида {годы{}} соответсвует тому, что он будет использован только для "средней" сложности и пропущен на "легкой", т.к. вложенные скобки ничего не содержат. Таким образом можно добавить любую сложность, в принципе, но на текущий момент в разметке используются только две, а в приложении пока доступна только "легкая".


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


Verse.prototype = {
  // …
  /**
   * Returns string, which normalized to passed difficulty (removes other difficulties' markup)
   * @param {String} string
   * @param {String} difficulty
   * @returns {String}
   */
  normalizeStringToDifficulty: function(string, difficulty) {
    var self = this;

    // Convert difficulty string into int of complexity
    var complexity = difficulty === self.DIFFICULTY_EASY ? 1 : 2;

    // Normalize verse content to passed difficulty by removing block separators for other difficulties
    // (if somebody know easier solution, drop me pull request :)
    var contentArray = string.split('');
    var startCharPositions = [];
    var endCharPositions = [];
    contentArray.forEach(function(char, index){
      // Count separators
      switch (char) {
        case self.BLOCK_SEPARATOR_START:
          startCharPositions.push(index);
          break;

        case self.BLOCK_SEPARATOR_END:
          endCharPositions.push(index);
          break;
      }

      // Check if we counted all separators for one block (Note: current algo is not working with mixed groups)
      if (startCharPositions.length && startCharPositions.length === endCharPositions.length) {
        var blockComplexity = Math.min(startCharPositions.length, complexity); // if block has lower complexity, use its maximum

        // Cleanup unnecessary blocks' separators
        startCharPositions.reverse().forEach(cleanup);
        endCharPositions.forEach(cleanup);

        // Reset stored positions
        startCharPositions = [];
        endCharPositions = [];
      }
    });

    return contentArray.join('');

    function cleanup(position, index) {
      if (index + 1 !== blockComplexity) {
        delete contentArray[position];
      }
    };
  }

  // …

  /**
   * Returns content divided into pieces to display it later
   * @param options
   * @returns {Array}
   */
  getPieces: function(options) {
    var self = this;

    options = angular.extend({
      difficulty: 'easy',
    }, options);

    // Get normalized content
    var contentArray = self.normalizeStringToDifficulty(self.content, options.difficulty).split('');

    // Divide into pieces
    var pieces = [];
    var isInBlock = false;
    var blockPiece = null;
    contentArray.forEach(function(char){
      switch (char) {
        case self.BLOCK_SEPARATOR_START:
          isInBlock = true;
          blockPiece = '';
          break;

        case self.BLOCK_SEPARATOR_END:
          isInBlock = false;
          if (blockPiece.length) {
            pieces.push(new VerseBlock(blockPiece));
          }
          break;

        default:
          if (isInBlock) {
            blockPiece += char;
          } else {
            pieces.push(char);
          }
      }
    });

    return pieces;
  }

Тестирование


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


Safari, что ты делаешь…

Определенную боль принесла оптимизация под iOS Safari, в основном из-за того, что программно нельзя поставить фокус на поле ввода, если пользователь не совершил никаких touch-действий. Поэтому там пришлось добавить специальную подсказку, чтобы пользователь тапнул в любое место на экране — только тогда мы можем установить фокус на нужном элементе, что немного портит юзабилити. Если кто-то знает, как решить эту проблему — пишите! Моя последняя попытка здесь https://jsfiddle.net/6tfrh7qn/5/ (открывайте в iPhone Simulator). Еще не нашел как отстайлить каретку.




И всё равно там какой-то баг с курсором, который может не отображаться после фокуса на поле.


User Testing

В процессе разработки регулярно проводился User Testing (в основном на родных и близких) чтобы выявить ошибки в UI, UX (пользовательский опыт) и вообще проверить корректность подачи идеи. Тесты дали очень хорошие плоды, позволив существенно улучшить UX.


На одном из тестов выяснилось, что пользователь думал, что в месте останова повествования нужно писать всё, что помнишь дальше. Тогда первая подсказка была изменена с "Начните печатать и закончите стихотворение" на "Начните печатать следующее слово".


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


В общем, очень полезная практика, не пренебрегайте юзер-тестами!


Unit/E2E testing

Также весь код покрыт Unit-тестами и E2E-тестами, не в каждом проекте удается выкроить на это время. Писать их было одно удовольствие, местами разработка шла по TDD. Да, E2E тесты на Protractor могут сфейлиться, если окно браузера, запускаемого тестом, на заднем плане или невидимо. Если кто знает, как это исправить, просьба сообщить.


Подводя итоги


Веб-приложение: http://literator.io
Репозиторий приложения: https://github.com/bobrosoft/literator.io
Репозиторий стихов: https://github.com/bobrosoft/literator.io-verses


Разработка шла неспешно, и прошло уже более года с момента её старта. Хоть и основная часть была завершена довольно быстро, потребовалось время, чтобы всё отполировать и закончить — принцип Парето в действии :) Иногда пропадало желание продолжать, т.к. в голову приходили совершенно новые идеи, но я сделал усилие, а то этот гештальт не давал бы покоя.


Думаю, что для начала добавил все стихи, которые должны быть известны большинству из нас. Старался пока не брать длинные стихи (хотя есть "Бородино"), чтобы не утомлять пользователя. Если незаслуженно пропустил что-то, напишите в комментариях, добавлю.


Идеи развития проекта (в порядке важности):


  • добавить кнопку "Я не помню", чтобы пропустить незнакомый стих?
  • изменить отображение результата, сделать его интереснее, показывать на сколько хорошо помнишь конкретное стихотворение / на сколько хорошая память (пожелание с одного из юзер-тестов)?
  • мультиязычность и разный набор стихов/авторов для разных стран (еще до конца не реализовано, но задел есть)?
  • добавить список стихов?
  • рисунки на полях, появляющиеся по мере повествования (Пушкин любил рисовать, у разных авторов былы бы свои; задел для этого в файловой структуре есть)?
  • добавить звуковое сопровождение в виде классической музыки под настроение стихотворения (в метаданных стиха есть поле "mood" как раз для этого). Если кто-то знает хороший источник Creative Commons музыки, где можно найти классику в хорошем качестве, просьба поделиться.?

Спасибо за внимание! Надеюсь, кому-то этот проект покажется интересным и принесет положительные эмоции :)


UPD: кому интересно дальнейшее развитие проекта, вступайте в группу https://vk.com/literatorio или https://www.facebook.com/LiteratorioApp/ чтобы не пропустить анонс обновлений.

Поделиться с друзьями
-->

Комментарии (23)


  1. 3aicheg
    05.10.2016 03:29
    +1

    Даавдым-давдо, в далёкой галактике существовал проект «Из песни… не выкинешь»


  1. phozzy
    05.10.2016 08:50
    +3

    Можно это как «суровую» капчу использовать.


  1. jbubsk
    05.10.2016 09:30
    +1

    Полагаясь на тэг AngularJS, ожидал здесь хоть строчечку кода евойного узреть.


    1. bobro
      05.10.2016 09:44

      да там всё тривиально :) могу выложить код основного контроллера, где сам стих выводится https://github.com/bobrosoft/literator.io/blob/master/app/scripts/controllers/verse.js. Всё олдскул, на компоненты не переводил.


      1. jbubsk
        05.10.2016 11:16
        +1

        Да, вопрос не в тривиальтности, а в том что тэг AngularJS добавлен не по содержанию.


  1. hdfan2
    05.10.2016 10:25

    Очень неплохо. Но совсем не понравилось, как сделана подсказка. Во-первых, при вводе (правильном) текст помаргивает (причём не весь, а кроме первой буквы). А при неправильном вводе как-то совсем неочевидно показывает (сначала несколько правильных букв подсказки, потом апостроф, потом неправильные введённый текст). Может быть, лучше правильный текст красить зелёным, а неверно введённые буквы красным? И делать подсказку так: заменять весь неправильный текст на (прошу прощения) 3 буквы верного? Просто как предложение.

    P.S. Firefox 49, Windows 7

    P.S. Попалось «Ночь, улица, фонарь, аптека…». Первый вопрос: «Ночь, улица, ?». Следующий «Ночь, улица, фонарь, ?». Для ручной расстановки заданий как-то уж слишком просто.


    1. bobro
      05.10.2016 10:39

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


      Про "Ночь, улица, фонарь, аптека…" согласен :) Просто оно и так короткое, но так — да, сегодня поправлю.


      1. hdfan2
        05.10.2016 11:04

        Тоже не очень. Полагаться на звук нельзя. Не у всех он включен (это я про себя). Тогда уж показать неверную букву красным (и одновременно дать звук. оповещение), и через секунду удалить её.


        1. bobro
          05.10.2016 11:15

          спасибо, учту.


        1. Adminisrator
          06.10.2016 11:38

          Делайте приложение для смартфонов, больше заработаете. Мне кажется это должно стрельнуть! Любителям русской классики особенно.


  1. SanekZhitnik
    05.10.2016 10:33

    Очень интересно. А почему слово автоматически появляется если ввести (угадать) первые три буквы слова?


    1. bobro
      05.10.2016 10:36

      это специально, чтобы пользователь не уставал, так и было задумано. Кто-то печатает медленно (я об "обычных" людях), особенно с мобильных девайсов.


  1. Radogost2016
    05.10.2016 11:16

    Всегда любил стихи, а теперь смогу потренироваться запоминать и вспоминать :)

    Можно ещё придумать сохранение статистики и список произведений для повторения ( ну тех, где пользователь много ошибался при вводе )


    1. bobro
      05.10.2016 11:16

      можно будет добавить, на основе localStorage. Для начала я бы хотел переработать выводимый результат на более интересный, а не просто затраченное время, потом можно и об остальном подумать :)


  1. Vjatcheslav3345
    05.10.2016 12:27

    Не стоит ограничиваться только русским языком — можно добавить туда другие и другие типы текстов — например английские и не только реплики из фильмов ("А Вас, Штирлиц...") или, скажем, иллюстрации книг, части комикса по которым надо узнать источник.


    1. bobro
      05.10.2016 12:43

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


  1. apk51
    05.10.2016 14:43

    Хороший проект и хорошие планы развития.
    Хочу предложить расширение проекта в другие области:
    Использование знаков препинания в русском языке достаточно сложно и индивидуально. Если пользователю дать возможность расставить в тексте классика знаки препинания по своему, а потом сравнить с авторским вариантом — это заинтересовало бы многих, кто сам пытается писать, да и просто любителей литературы.
    Можно использовать тексты и на других языках.
    Можно расставлять не только знаки препинания, но и, например, предлоги или артикли в английском (где "а", где "the", а где и вовсе без артикля).


    1. bobro
      05.10.2016 14:56

      Хорошее предложение, но другое направление. Так же, на сколько я знаю, знаки препинания классики иногда расставляют индивидуально, не всегда по правилам. В те время могли быть и другие правила, поэтому "сомнительно". Но как электронная версия "Тотального диктанта" может и прошло бы.


  1. poman_k
    05.10.2016 14:43

    Попался стих «Бородино» — на нём с телефона после середины стихотворения съело всю память и начались дикие тормоза. Получалось так, что когда на экране появляется каретка, ввожу слово (верное), а там оказывается уже была напечатана одна буква и выходит что вроде "… Ведь были схватки бобоевые..."

    Телефон nexus 5, 2gb ОП


    1. bobro
      05.10.2016 14:52

      странно, не должно так сильно жрать. Но у меня для вывода букв пока ng-repeat используется (хоть и со статик-биндингом), а это не экономно, но было самое простое решение и, в принципе, рабочее. Исправлю. Оптимизация важна.


      "Получалось так, что когда на экране появляется каретка, ввожу слово (верное), а там оказывается уже была напечатана одна буква" — этот кейс частично решен тем, что для удачного завершения слова можно всё равно ввести первые его буквы, т.е. не обязательно стирать и вводить именно с текущей позиции. Т.е. "бобое" должно срабатывать.


  1. LemonFox
    05.10.2016 16:44

    После 404 идет редирект, если жать назад то кидает обратно на 404.
    Ну и на самой 404 нет навигации хотя бы на главную (пытался открыть стих напрямую по ссылке, но где-то опечатался).

    Из дополнительного

    добавить кнопку «Я не помню», чтобы пропустить незнакомый стих

    Хотелось бы еще кнопку «Пропустить слово»/«Не помню слово» чтобы не ждать пока он заполнится.
    В целом неплохая идея и реализация.


    1. bobro
      05.10.2016 16:47

      "Ну и на самой 404 нет навигации хотя бы на главную" — упс %) Надо поправить, спасибо за репорт! А как туда попали? Руками вводили?


  1. bobro
    05.10.2016 16:46

    [comment deleted]