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

от автора

Недавно я публиковал статью на Хабре про гитарный тюнер, и многих заинтересовали анимированные графики которые я использовал для иллюстрации звуковых волн, в том числе технология создания таких графиков. Поэтому в этой статье я поделюсь своим подходом и библиотечкой на 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-лицензией. Поэтому можете смело использовать интересующие вас участки кода или весь код проекта в своих целях.

ссылка на оригинал статьи http://habrahabr.ru/post/269931/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *