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