О том, как рисовать кривые графики в стиле XKCD

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

d2ed2ca89c1f42f39f2b71032fe53a59.png

Предыстория


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


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

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

d515ed96835741b782bde4b9054ae371.png

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

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


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

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

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

Почему Node.js?


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

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

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


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

4c7c50149d9448dea42554808805dad4.png

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

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

    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;
    };

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

b0bd38270f2b40f4a606c0e8a21b716c.png

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

Метод 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();

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

b410a280bacf478a9d24b773fd57d24f.png

Библиотечка 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);

86377f9f0c4041b285ca430e5633cc98.png

Анимация

Добиться движущегося изображения на 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 вращающаяся спираль:

5a9fda0b41c34d0f928c5a132674b096.gif

Когда график отлажен, можно используя тот же файл 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();

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

b49a3825146b4133b38b70e9b9320c23.gif
Исходный код 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;
    }


c0f5a680e7c94a79956856d2fed02bd0.gif
Исходный код 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.

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

f62214bb9e0c425aba041f6f32926bf4.pnggithub.com/kreshikhin/clumsy

96d20f4656df4d0a89686d8b38722606.iconpmjs.com/package/clumsy

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

© Habrahabr.ru