Про те, як малювати криві графіки в стилі 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-ліцензією. Тому можете сміливо використовувати вас цікавлять ділянки коду або весь код проекту в своїх цілях.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.