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



Предыстория


Зачем делать графики кривыми?


Вообще, идея создания кривых графиков идет из академической культуры — не только российской, но и мировой.

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

Именно на этом нюансе создаются комиксы XKCD, юмор которых базируется на простых зависимостях интерпретируемых в некоторой необычной манере:



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

Зачем писать скрипты для построения графиков?


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

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

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

Почему Node.js?


Существует много библиотек для построение графиков, в том числе с эффектом XKCD, есть расширения для matplotlib и специальный пакет для R. Тем не менее, Javascript имеет ряд преимуществ.

Для Javascript доступен довольной удобный браузерный Canvas и Node.js-библиотеки которые реализуют это поведение. В свою очередь, скрипт написанный для Canvas можно воспроизвести в браузере, что позволяет, например, отображать данные на сайте динамически. Так же Canvas удобен для отладки анимации в браузере, т.к. отрисовка происходит фактически на лету. Имея скрипт отрисовки на Node.js можно задействовать пакет GIFEncoder, который позволяет очень просто создать анимированный ролик.

Добавление искривлений


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



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

Описанное поведение может быть реализовано следующим образом:

    self.replot = function(line, step, radius){
        var accuracy = 0.25;

        if(line.length < 2) return [];
        var replottedLine = [];

        var beginning = line[0];
        replottedLine.push(beginning);

        for(var i = 1; i < line.length; i++){
            var point = line[i];
            var dx = point.x - beginning.x;
            var dy = point.y - beginning.y;
            var d = Math.sqrt(dx*dx+dy*dy);

            if(d < step * (1 - accuracy) && (i + 1 < line.length)){
                // too short
                continue;
            }

            if(d > step * (1 + accuracy)){
                // too long
                var n = Math.ceil(d / step);
                for(var j = 1; j < n; j++){
                    replottedLine.push({
                        x: beginning.x + dx * j / n,
                        y: beginning.y + dy * j / n
                    });
                }
            }

            replottedLine.push(point);
            beginning = point;
        };

        for(var i = 1; i < replottedLine.length; i++){
            var point = replottedLine[i];
            replottedLine[i].x = point.x + radius * (self.random() - 0.5);
            replottedLine[i].y = point.y + radius * (self.random() - 0.5);
        };

        return replottedLine;
    };


Результат такой обработки:


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

Метод quadraticCurveTo из Canvas представляет отрисовку с достаточной для наших задач гладкостью, но при этом требует вспомогательные узлы. Эти узлы могу быть рассчитаны на основе опорных точек полученных на предыдущем шаге:

    ctx.beginPath();
    ctx.moveTo(replottedLine[0].x, replottedLine[0].y);

    for(var i = 1; i < replottedLine.length - 2; i ++){
        var point = replottedLine[i];
        var nextPoint = replottedLine[i+1];

        var xc = (point.x + nextPoint.x) / 2;
        var yc = (point.y + nextPoint.y) / 2;
        ctx.quadraticCurveTo(point.x, point.y, xc, yc);
    }

    ctx.quadraticCurveTo(replottedLine[i].x, replottedLine[i].y, replottedLine[i+1].x,replottedLine[i+1].y);
    ctx.stroke();


Полученная сглаженная линия как раз и будет соответствовать небрежному начертанию:



Библиотечка Clumsy



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

В случае Node.js процесс инициализации выглядит примерно так:

    var Canvas = require('canvas');
    var Clumsy = require('clumsy');

    var canvas = new Canvas(800, 600);
    var clumsy = new Clumsy(canvas);


Основные методы класса, необходимы для отрисовки простейшего графика:

    range(xa, xb, ya, yb); // задает границы сетки графика
    padding(size); // размер отступа в пикселах
    draw(line); // отрисовывает линию
    axis(axis, a, b); // отрисовывает ось
    clear(color); // очищает canvas заданным цветом

    tabulate(a, b, step, cb); // вспомогательный метод для табулирования данных


Более полный список методов и полей, а так же их описание и примеры использования можно найти в документации проекта на npm.

Как это работает можно продемонстрировать на примере синуса:

    clumsy.font('24px VoronovFont');
    clumsy.padding(100);
    clumsy.range(0, 7, 2, 2);

    var sine = clumsy.tabulate(0, 2*Math.PI, 0.01, Math.sin);
    clumsy.draw(sine);

    clumsy.axis('x', 0, 7, 0.5);
    clumsy.axis('y', -2, 2, 0.5);

    clumsy.fillTextAtCenter("Синус", 400, 50);




Анимация



Добиться движущегося изображения на Canvas'е в браузере довольно просто, достаточно обернуть алгоритм отрисовки в функцию и передать в setInterval. Такой подход удобен в первую очередь для отладки, т.к. результат наблюдается непосредственно. Что же касается генерации готового gif'а на Node.js, то в этом случае можно воспользоваться библиотекой GIFEncoder.

Для примера, возьмем спираль Архимеда, которую заставим вращаться со скоростью pi радиан в секунду.
Когда требуется анимировать некоторый график удобнее всего сделать отдельный файл отвечающий исключительно за отрисовку, и отдельно файлы настраивающие параметры анимации — fps, длительность ролика, и т.п. Назовем скрипт отрисовки spiral.js и создадим в нем функцию Spiral:

    function Spiral(clumsy, phase){
        clumsy.clear('white');
        clumsy.padding(100);
        clumsy.range(-2, 2, -2, 2);

        clumsy.radius = 3;

        var spiral = clumsy.tabulate(0, 3, 0.01, function(t){
            var r = 0.5 * t;
            return {
                x: r * Math.cos(2 * Math.PI * t + phase),
                y: r * Math.sin(2 * Math.PI * t + phase)
            };
        })

        clumsy.draw(spiral);

        clumsy.axis('x', -2, 2, 0.5);
        clumsy.axis('y', -2, 2, 0.5);

        clumsy.fillTextAtCenter('Спираль', clumsy.canvas.width/2, 50);
    }

    // Костыль для предотвращения экспорта в браузере
    if(typeof module != 'undefined' && module.exports){
        module.exports = Spiral;
    }


Затем можно просмотреть результат в браузере, сделав отладочную страницу:

    <!DOCUMENT html>
    <script src="https://rawgit.com/kreshikhin/clumsy/master/clumsy.js"></script>
    <link rel="stylesheet" type="text/css" href="http://webfonts.ru/import/voronov.css"></link>
    <canvas id="canvas" width=600 height=600>
    <script src="spiral.js"></script>
    <script>
        var canvas = document.getElementById('canvas');
        var clumsy = new Clumsy(canvas);

        var phase = 0;
        setInterval(function(){
            // Фиксированный seed предотвращает "дрожание" графика
            clumsy.seed(123);

            Spiral(clumsy, phase);
            phase += Math.PI / 10;
        }, 50);
    </script>


Отладка в браузере удобна тем, что результат появляется сразу же. Т.к. не требуется время на генерацию кадров и сжатие в формат GIF. Что может занять несколько минут. Сохранив страницу в .html формате и открыв в браузере мы должны увидеть на Canvas вращающаяся спираль:



Когда график отлажен, можно используя тот же файл spiral.js создать скрипт для генерации GIF-файла:

    var Canvas = require('canvas');
    var GIFEncoder = require('gifencoder');
    var Clumsy = require('clumsy');
    var helpers = require('clumsy/helpers');
    var Spiral = require('./spiral.js');

    var canvas = new Canvas(600, 600);
    var clumsy = new Clumsy(canvas);

    var encoder = helpers.prepareEncoder(GIFEncoder, canvas);
    var phase = 0;
    var n = 10;

    encoder.start();
    for(var i = 0; i < n; i++){
        // Фиксированный seed предотвращает "дрожание" графика
        clumsy.seed(123);

        Spiral(clumsy, phase);

        phase += 2 * Math.PI / n;
        encoder.addFrame(clumsy.ctx);
    };

    encoder.finish();


Абсолютно аналогичным образом я создавал графики для иллюстрации явления стоячей волны:


Исходный код scituner-standing-group.js
    function StandingGroup(clumsy, shift){
        var canvas = clumsy.canvas;

        clumsy.clean('white');
        clumsy.ctx.font = '24px VoronovFont';
        clumsy.padding(100);
        clumsy.range(0, 1.1, -1, 1);
        clumsy.radius = 3;
        clumsy.step = 10;
        clumsy.lineWidth(2);

        clumsy.color('black');
        clumsy.axis('x', 0, 1.1);
        clumsy.axis('y', -1, 1);

        var f0 = 5;

        var wave = clumsy.tabulate(0, 1.01, 0.01, function(t0){
            var dt = shift / f0;
            var t = t0 + dt;
            return 0.5 * Math.sin(2*Math.PI*f0*t) * Math.exp(-15*(t0-0.5)*(t0-0.5));
        });

        clumsy.color('red');
        clumsy.draw(wave);

        clumsy.fillTextAtCenter("Стоячая волна, Vгр = 0", canvas.width/2, 50);
        clumsy.fillText("x(t)", 110, 110);
        clumsy.fillText("t", 690, 330);
    }

    if(typeof module != 'undefined' && module.exports){
        module.exports = StandingGroup;
    }





Исходный код scituner-standing-phase.js
    function StandingPhase(clumsy, shift){
        var canvas = clumsy.canvas;

        clumsy.clean('white');

        clumsy.ctx.font = '24px VoronovFont';
        clumsy.lineWidth(2);
        clumsy.padding(100);
        clumsy.range(0, 1.1, -2, 2);
        clumsy.radius = 3;
        clumsy.step = 10;

        clumsy.color('black');
        clumsy.axis('x', 0, 1.1);
        clumsy.axis('y', -2, 2);

        var f = 5;

        var wave = clumsy.tabulate(0, 1.01, 0.01, function(t0){
            var t = t0 + shift;
            return Math.sin(2*Math.PI*f*t0) * Math.exp(-15*(t-0.5)*(t-0.5));
        });

        clumsy.color('red');
        clumsy.draw(wave);

        clumsy.fillTextAtCenter("Стоячая волна, Vф = 0", canvas.width/2, 50);
        clumsy.fillText("x(t)", 110, 110);
        clumsy.fillText("t", 690, 330);
    }

    if(typeof module != 'undefined' && module.exports){
        module.exports = StandingPhase;
    }



Заключение


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

Она не универсальна, но если необходимо построить довольно простой график в стиле XKCD, то с этой задачей она справляется более чем хорошо. Дополнительные возможности можно реализовывать самостоятельно используя возможности HTML5 Canvas.

Полную документацию и примеры можно найти по этим ссылкам:

github.com/kreshikhin/clumsy

npmjs.com/package/clumsy

Исходный код сопровождён MIT-лицензией. Поэтому можете смело использовать интересующие вас участки кода или весь код проекта в своих целях.

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


  1. Barttos
    02.11.2015 02:05
    +6

    Тут еще один вариант для искривление линий: bl.ocks.org/dfm/3914862 (function xinterp)


  1. kekekeks
    02.11.2015 02:21
    +23

    Эцсамое, при анимации графика оси не должны вибрировать, как мне кажется. Возможно, имеет смысл разбить изображение на статичный фон и анимированный график.


    1. savostin
      02.11.2015 04:13
      +20

      А мне наоборот симпатичны эти смешные стрелочки.


      1. Areso
        02.11.2015 08:23
        +9

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


        1. darkfrei
          03.11.2015 21:43

          И будут стрелочные холивары!


    1. deniskreshikhin
      02.11.2015 10:06
      +4

      Да, я тоже думал над этим. Как все-таки лучше.

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

      PS Пожалуй подумаю, как это вынести в опцию.


    1. samodum
      02.11.2015 13:44

      Нет, они должны вибрировать



  1. Find_the_truth
    02.11.2015 08:21
    +12

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


    1. deniskreshikhin
      02.11.2015 10:14
      +5

      Спасибо, придерживаюсь такого же мнения)


      1. Halt
        02.11.2015 12:30
        +2

        Я кстати подозреваю, что ноги у этого эффекта растут оттуда-же, что и у зловещей долины.

        Идеально прямые линии начинают задавать стиль оформления всей графики. Соответственно, неизбежные отклонения от пропорций, несоответствие цвета и стилей воспринимаются как «хреновый дизайн».

        Рисунок «как попало» смещает восприятие в сторону мультяшности, где критерии оценки совсем другие.


        1. Yuuri
          02.11.2015 14:43

          Может быть, оттуда же корни любви к винилу и прочему тёплому ламповому звуку?


          1. Halt
            02.11.2015 15:23

            Я думаю там корни лежат в человеческих комплексах и желании выделиться (хотя бы в собственных глазах).

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


  1. Jenyay
    02.11.2015 08:36
    +3

    В Matplotlib (библиотека для построения графиков в Python) тоже есть такой фильтр.


  1. bromzh
    02.11.2015 08:49
    +9

    Добавлю свои 5 копеек. Вариант для питона


  1. Meklon
    02.11.2015 10:59
    +2

    Отлично. Спасибо, что не забыли)


  1. bfDeveloper
    02.11.2015 12:41
    +6

    Интересная идея и реализация, но хочется кое-что добавить. Меня в школе научили, что график должен быть качественно верным. То есть не очень важно, насколько верно нарисован весь график, насколько дрожала рука, но есть несколько важных точек, в которых всё должно быть идеально, потому что от этого может зависеть вся интерпретация. Например, синус должен пересекать ось ровно в точках кратных пи. Простая несмещённая парабола должна пересекать ось x только в ноле, ни в коем случае не быть отрицательной. Если на одной плоскости графика два, то точки пересечения должны быть рассчитаны и точно изображены. Гипербола должна стремиться к асимптотам, но не пересекать.
    Может быть есть идеи, как подобные требования добавить к вашей реализации?


    1. Yuuri
      02.11.2015 14:50

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


    1. Halt
      02.11.2015 15:21
      +2

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


      1. bfDeveloper
        02.11.2015 19:48

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


  1. iilinegor
    02.11.2015 15:54

    Ещё есть вариант c svg-фильтрами

    Собственно код
    @keyframes anim {
      0% {
                filter: url("#XKCDisation-0");
      }
      25% {
                filter: url("#XKCDisation-1");
      }
      50% {
                filter: url("#XKCDisation-2");
      }
      75% {
                filter: url("#XKCDisation-3");
      }
      100% {
                filter: url("#XKCDisation-4");
      }
    }
    


    <filter id="XKCDisation-0">
          <feTurbulence id="turbulence" baseFrequency="0.02" numOctaves="3" result="noise" seed="0"/>
          <feDisplacementMap id="displacement" in="SourceGraphic" in2="noise" scale="6" />
    </filter>
    
    <filter id="XKCDisation-1">
          <feTurbulence id="turbulence" baseFrequency="0.02" numOctaves="3" result="noise" seed="1"/><
          <feDisplacementMap id="displacement" in="SourceGraphic" in2="noise" scale="8" />
    </filter>
    
    <filter id="XKCDisation-2">
          <feTurbulence id="turbulence" baseFrequency="0.02" numOctaves="3" result="noise" seed="2"/>
          <feDisplacementMap id="displacement" in="SourceGraphic" in2="noise" scale="6" />
    </filter>
    
    <filter id="XKCDisation-3">
          <feTurbulence id="turbulence" baseFrequency="0.02" numOctaves="3" result="noise" seed="3"/>
          <feDisplacementMap id="displacement" in="SourceGraphic" in2="noise" scale="8" />
    </filter>
    
    <filter id="XKCDisation-4">
          <feTurbulence id="turbulence" baseFrequency="0.02" numOctaves="3" result="noise" seed="4"/>
          <feDisplacementMap id="displacement" in="SourceGraphic" in2="noise" scale="6" />
    </filter>