Rough.js: как заставить компьютер рисовать «от руки»

Слишком минималистичная, ровная графика может приедасться. Простой способ внести приятную шероховатость – библиотека Rough.js. Показываем примеры кода и применения.

Rough.js – небольшая графическая библиотека (< 9 кБ в gzip), позволяющая рисовать на JavaScript в скетчевом стиле, то есть получать графику, как бы сделанную от руки. Пакет определяет примитивы для рисования линий, кривых, дуг, многоугольников, кругов и эллипсов.

В середине января вышла версия 4.0. В этой публикации мы постараемся обединить известные данные и нововведения.

Rough.js работает как с Canvas, так и с SVG.

Установка

Устанавливаем Rough.js из менеджера пакетов:

npm install --save roughjs

Используем в коде:

import rough from 'roughjs';

Примеры использования

Полная документация API Rough.js доступна на Github. Приведём сначала несколько простых примеров графических элементов и кода, с помощью которого они были получены.

Прямоугольник

const rc = rough.canvas(document.getElementById('canvas'));
rc.rectangle(10, 10, 200, 200); // x, y, ширина, высота

Для SVG-варианта:

const rc = rough.svg(svg);
let node = rc.rectangle(10, 10, 200, 200); // x, y, ширина, высота
svg.appendChild(node);

Линии и эллипсы

rc.circle(80, 120, 50); // centerX, centerY, диаметр
rc.ellipse(300, 100, 150, 80); // centerX, centerY, ширина, высота
rc.line(80, 120, 300, 100); // x1, y1, x2, y2

Заполнение

rc.circle(50, 50, 80, { fill: 'red' }); // fill with red hachure
rc.rectangle(120, 15, 80, 80, { fill: 'red' });
rc.circle(50, 150, 80, {
  fill: "rgb(10,150,10)",
  fillWeight: 3 // thicker lines for hachure
});
rc.rectangle(220, 15, 80, 80, {
  fill: 'red',
  hachureAngle: 60, // angle of hachure,
  hachureGap: 8
});
rc.rectangle(120, 105, 80, 80, {
  fill: 'rgba(255,0,200,0.2)',
  fillStyle: 'solid' // solid fill
});

Стили заливки фигур могут быть следующие: обычная штриховка (по умолчанию), сплошной цвет, зигзагообразное заполнение, перекрёстная штриховка или заполнение точками.

Стиль зарисовки

rc.rectangle(15, 15, 80, 80, { roughness: 0.5, fill: 'red' });
rc.rectangle(120, 15, 80, 80, { roughness: 2.8, fill: 'blue' });
rc.rectangle(220, 15, 80, 80, { bowing: 6, stroke: 'green', strokeWidth: 3 });

Рисование в формате SVG-path

rc.path('M80 80 A 45 45, 0, 0, 0, 125 125 L 125 80 Z', { fill: 'green' });
rc.path('M230 80 A 45 45, 0, 1, 0, 275 125 L 275 80 Z', { fill: 'purple' });
rc.path('M80 230 A 45 45, 0, 0, 1, 125 275 L 125 230 Z', { fill: 'red' });
rc.path('M230 230 A 45 45, 0, 1, 1, 275 275 L 275 230 Z', { fill: 'blue' });

Примеры посложнее

const rc = rough.canvas(document.getElementById('canvas'));

//line and rectangle
rc.rectangle(10, 10, 100, 100);
rc.rectangle(140, 10, 100, 100, {
  fill: 'rgba(255,0,0,0.2)',
  fillStyle: 'solid',
  roughness: 2
});
rc.rectangle(10, 130, 100, 100, {
  fill: 'red',
  stroke: 'blue',
  hachureAngle: 60,
  hachureGap: 10,
  fillWeight: 5,
  strokeWidth: 5
});

// ellipse and circle
rc.ellipse(350, 50, 150, 80);
rc.ellipse(610, 50, 150, 80, {
  fill: 'blue'
});
rc.circle(480, 50, 80, {
  stroke: 'red', strokeWidth: 2,
  fill: 'rgba(0,255,0,0.3)', fillStyle: 'solid'
});

//overlapping circles
rc.circle(480, 150, 80, {
  stroke: 'red', strokeWidth: 4,
  fill: 'rgba(0,255,0,1)', fillWeight: 4, hachureGap: 6
});
rc.circle(530, 150, 80, {
  stroke: 'blue', strokeWidth: 4,
  fill: 'rgba(255,255,0,1)', fillWeight: 4, hachureGap: 6
});

// linearPath and polygon
rc.linearPath([[690, 10], [790, 20], [750, 120], [690, 100]], {
  roughness: 0.7,
  stroke: 'red', strokeWidth: 4
});
rc.polygon([[690, 130], [790, 140], [750, 240], [690, 220]]);
rc.polygon([[690, 250], [790, 260], [750, 360], [690, 340]], {
  stroke: 'red', strokeWidth: 4,
  fill: 'rgba(0,0,255,0.2)', fillStyle: 'solid'
});
rc.polygon([[690, 370], [790, 385], [750, 480], [690, 460]], {
  stroke: 'red',
  hachureAngle: 65,
  fill: 'rgba(0,0,255,0.6)'
});

// arcs
rc.arc(350, 200, 200, 180, Math.PI, Math.PI * 1.6);
rc.arc(350, 300, 200, 180, Math.PI, Math.PI * 1.6, true);
rc.arc(350, 300, 200, 180, 0, Math.PI / 2, true, {
  stroke: 'red', strokeWidth: 4,
  fill: 'rgba(255,255,0,0.4)', fillStyle: 'solid'
});
rc.arc(350, 300, 200, 180, Math.PI / 2, Math.PI, true, {
  stroke: 'blue', strokeWidth: 2,
  fill: 'rgba(255,0,255,0.4)'
});

// draw sine curve
let points = [];
for (let i = 0; i < 20; i++) {
  // 4pi - 400px
  let x = (400 / 20) * i + 10;
  let xdeg = (Math.PI / 100) * x;
  let y = Math.round(Math.sin(xdeg) * 90) + 500;
  points.push([x, y]);
}
rc.curve(points, {
  roughness: 1.2, stroke: 'red', strokeWidth: 3
});
const rc = rough.canvas(document.getElementById('canvas'), {
  async: true,
  options: {
	simplification: 0.2, roughness: 0.65
  }
});
const width = 960, height = 500;
const projection = d3.geo.albersUsa().scale(1070).translate([width / 2, height / 2]);
const path = d3.geo.path().projection(projection);
const randomColor = () => {
  let r = `rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})`;
  return r;
}
const randomAngle = () => {
  return (Math.random() > 0.5 ? -1 : 1) * (1 + Math.random() * 88);
}
const randomStyle = () => {
  return (Math.random() > 0.5 ? 'solid' : '');
}
d3.json("./us.json", async (error, us) => {
  if (error) throw error;
  let topo = topojson.feature(us, us.objects.states).features;
  for (let feature of topo) {
	rc.path(path(feature), {
	  fill: randomColor(),
	  fillStyle: randomStyle(),
	  hachureAngle: randomAngle()
	});
  }
});
var canvas = document.getElementById('canvas');
const rc = rough.canvas(canvas, {
  options: {
	fill: "blue",
	roughness: 0.8,
	bowing: 0.7
  }
});

var context = canvas.getContext("2d");
var margin = { top: 20, right: 20, bottom: 30, left: 40 },
  width = canvas.width - margin.left - margin.right,
  height = canvas.height - margin.top - margin.bottom;
var x = d3.scaleBand()
  .rangeRound([0, width])
  .padding(0.1);
var y = d3.scaleLinear()
  .rangeRound([height, 0]);
context.translate(margin.left, margin.top);

d3.tsv("data.tsv", function (d) {
  d.frequency = +d.frequency;
  return d;
}, function (error, data) {
  if (error) throw error;

  x.domain(data.map(function (d) { return d.letter; }));
  y.domain([0, d3.max(data, function (d) { return d.frequency; })]);

  var yTickCount = 10,
	yTicks = y.ticks(yTickCount),
	yTickFormat = y.tickFormat(yTickCount, "%");

  data.forEach(function (d) {
	rc.rectangle(x(d.letter), y(d.frequency), x.bandwidth(), height - y(d.frequency));
  });


  context.beginPath();
  x.domain().forEach(function (d) {
	context.moveTo(x(d) + x.bandwidth() / 2, height);
	context.lineTo(x(d) + x.bandwidth() / 2, height + 6);
  });
  context.strokeStyle = "black";
  context.stroke();

  context.textAlign = "center";
  context.textBaseline = "top";
  x.domain().forEach(function (d) {
	context.fillText(d, x(d) + x.bandwidth() / 2, height + 6);
  });

  context.beginPath();
  yTicks.forEach(function (d) {
	context.moveTo(0, y(d) + 0.5);
	context.lineTo(-6, y(d) + 0.5);
  });
  context.strokeStyle = "black";
  context.stroke();

  context.textAlign = "right";
  context.textBaseline = "middle";
  yTicks.forEach(function (d) {
	context.fillText(yTickFormat(d), -9, y(d));
  });

  context.beginPath();
  context.moveTo(-6.5, 0 + 0.5);
  context.lineTo(0.5, 0 + 0.5);
  context.lineTo(0.5, height + 0.5);
  context.lineTo(-6.5, height + 0.5);
  context.strokeStyle = "black";
  context.stroke();

  context.save();
  context.rotate(-Math.PI / 2);
  context.textAlign = "right";
  context.textBaseline = "top";
  context.font = "bold 10px sans-serif";
  context.fillText("Frequency", -10, 10);
  context.restore();
});

Что нового в версии 4.0?

Задание поля случайных чисел. Rough.js вычисляет фигуры, генерируя много случайных смещений. В результате каждый рендер создаёт уникальную нарисованную от руки форму. Однако в некоторых случаях такое поведение нежелательно. Бывает необходимо, например, сохранить форму штриховки при разных размерах фигуры. API теперь позволяет пользователю передавать начальное значение seed, которое используется для генерации предсказуемой последовательности случайных чисел. Параметр является опциональным:

const seed = rough.newSeed();
rc.rectangle(10, 10, 200, 200, { fill: 'red', seed });
rc.rectangle(240, 10, 250, 250, { fill: 'blue', seed });

Новый алгоритм штриховки. Как описано выше, Rough.js может заполнить фигуру штриховыми линиями. Старый алгоритм был адаптацией кода Handy с небольшими изменениями. Код не очень хорошо обрабатывал вогнутые формы под некоторыми углами штриховки. Могли создаваться такие некорректные ситуации:

Новый алгоритм основан на алгоритме растеризации Scan-line. При создании линий штриховки строки обрабатываются пошагово с указанным межстроковым расстоянием hachureGap. Этот алгоритм, однако, предназначен для горизонтальных линий сканирования. Чтобы работать с различными углами штриховки, сначала производится поворот фигуры на угол hachureAngle и заполнение горизонтальными линиями. Потом найденные горизонтальные линии поворачиваются обратно на угол -hachureAngle, как показано на рисунке ниже.

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

Алгоритм теперь автоматически настраивается в зависимости от размера фигуры. Ниже приведён тот же набор окружностей, созданный с применением автоподстройки.

Фигура без контура

Другой часто запрашиваемой функцией было добавление возможности рисовать фигуры без контура. Этого можно было бы достигнуть путём установки прозрачного цвета обводки. Однако наличие контура раньше учитывалось при заполнении основной фигуры. Теперь эти инструкции разделены, и для контура можно просто указать значение none.

rc.rectangle(240, 10, 250, 250, { stroke: 'none', fill: 'blue' });

Заключение

Напоследок несколько примеров использования библиотеки:

  1. Wired elements – набор элементов на основе Rough.js.
  2. Змейка из элементов графики Rough.js
  3. Semiotic – визуализация данных для React, использующая Rough.js для рендеринга.
  4. Плагин Rough.js для Leaflet – библиотека JavaScript с открытым исходным кодом для мобильных интерактивных карт.
  5. React Bindings для Rough.js
  6. Инструмент для создания графиков, есть возможность использовать Rough.js

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

eFusion
01 марта 2020

ТОП-15 книг по JavaScript: от новичка до профессионала

В этом посте мы собрали переведённые на русский язык книги по JavaScript – ...
admin
10 июня 2018

Лайфхак: в какой последовательности изучать JavaScript

Огромный инструментарий JS и тонны материалов по нему. С чего начать? Расск...