Недавно я публиковал статью на Хабре про гитарный тюнер, и многих заинтересовали анимированные графики которые я использовал для иллюстрации звуковых волн, в том числе технология создания таких графиков. Поэтому в этой статье я поделюсь своим подходом и библиотечкой на 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.
Полную документацию и примеры можно найти по этим ссылкам:
Исходный код сопровождён MIT-лицензией. Поэтому можете смело использовать интересующие вас участки кода или весь код проекта в своих целях.
ссылка на оригинал статьи http://habrahabr.ru/post/269931/
Добавить комментарий