Всем привет! Сегодня хотелось бы рассказать про то, как можно сделать собственные графики на js + canvas буквально в пару сотен строк кода. А заодно вспомнить школьный курс геометрии.
Зачем
Есть достаточное количество крутых библиотек, умеющих строить графики в браузере. А d3js стала фактически стандартом де-факто. Однако, мне была нужна именно sunburst-диаграмма, поэтому не хотелось тащить за собой десятки или сотни килобайт библиотечного кода. Также была необходимость быстрой работы такой диаграммы в мобильных браузерах, следовательно реализация на svg не подходила в силу меньшей производительности последней по сравнению с canvas. Масштабирование диаграммы не предусматривалось. К тому же написание такой вещи — отличный способ следовать пути abc.
Что визуализируем
Данные для визуализации имеют примерно следующий формат:
{
name: 'day',
value: 24 * 60 * 60,
children: [
{
name: 'work',
value: 9 * 60 * 60,
children: [
{
name: 'coding',
value: 6 * 60 * 60,
children: [
{name: 'python', value: 4 * 60 * 60},
{name: 'js', value: 2 * 60 * 60}
]
},
{name: 'communicate', value: 1.5 * 60 * 60}
]
},
{name: 'sleep', value: 7 * 60 * 60},
...
};
Т.е. имеем иерархическую структуру, каждый узел которой имеет имя и некоторую величину (value). Отобразить их нужно в нечто подобное:
При этом необходимо предусмотреть возможность масштабирования диаграммы кликом на различные области, т.е. фактически переход по дереву данных. А также редактирование данных, т.е. добавление и удаление узлов.
Возможности
- Данные должны относительно равномерно располагаться на холсте, с соблюдением пропорций — чем дальше узел находится от центра, тем меньше места он должен занимать.
- Клик по узлу, не являющемуся в данный момент корневым, должен приводить к увеличению детализации диаграммы.
- Клик по центральному узлу должен приводить к уменьшению детализации диаграммы.
- Необходима реакция диаграммы при наведении курсора и два режима выделения узлов: отдельный узел и путь от центра диаграммы к выделенному узлу.
В результате хотелось бы получить некоторый гибрид этого и этого.
Реализация
Расположение данных
Для того, чтобы равномерно распределить данные по холсту, необходимо для начала выяснить их максимальную вложенность. Это достаточно легко сделать с помощью следующей рекурсивной функции:
function maxDeep(data) {
var deeps = [];
for (var i = 0, l = (data.children || []).length; i < l; i++) {
deeps.push(maxDeep(data.children[i]));
}
return 1 + Math.max.apply(Math, deeps.length ? deeps : [0]);
};
Глубина вложженности данных определяет количество слоев (колец) на диаграмме. Все кольца в сумме должны занимать min(canvas.width, canvas.height). Уменьшение ширины колец от центра (максимальный размер) к краям холста (минимальный размер) происходит по правилу золотого сечения. Каждое последующее кольцо тоньше предыдущего в 1,62 раза. Таким образом имеем рекурентное выражение:
x + x / 1.62 + x / (1.62^2) + ... + x / (1,62^(n-1)) = min(canvas.width, canvas.height)
где n — это количество слоев, а x — толщина корневого узла. Таким образом x можно легко найти с помощью следующей функции:
function rootNodeWidth(n, canvasWidth, canvasHeight) {
var canvasSize = Math.min(canvasWidth, canvasHeight), div = 1;
for (var i = 1; i < n; i++) {
div += 1 / Math.pow(1.62, i);
}
return canvasSize / 2 / div; // на 2 делим, так как ищем радиус корневого узла.
};
Далее при отрисовке диаграммы просто будем делить толщину текущего слоя на 1,62, чтобы получить толщину последующего.
Визуализация узлов диаграммы
Прежде, чем приступать непосредственно к отрисовке узлов, необходимо выполнить некоторые расчеты. В частности, необходимо посчитать высоту каждого из узлов, определить протяженность узла, угол начала и назначить цвет.
Про опредление высоты уже было написано выше. Углы же рассчитываются следующим образом: центральный узел является кругом, т.е. угол между его боковыми составляющими 360 градусов (2 Pi радиан) и они сливаются в одну линию (мы ее не визуализируем). Толщина центрального узла является радиусом этого круга.
Все последующие узлы являются дугами, обернутыми вокруг центрального узла.
Узлы первого (не центрального) уровня.
Длина дуги (т.е. угол между ее боковыми составляющими) вычисляется исходя из соотношения величины данных (value), которые соответстуют этой дуге и величины данных родительского для этой дуги узла. Таким образом, если центральный узел обладает значением value = 100, а вложенный в него узел первого уровня обладает value = 50, то угол последнего будет составлять 180 градусов (50 / 100 = Pi / 2 Pi). Это правило рекурсивно применяется для каждого из узлов по отношению к его родителю. Если узел имеет 2 и более наследников, то максимальный угол его первого наследника будет являться минимальным углом второго и так далее. Все расчеты идут по часовой стрелке.
Соотношение длин дуг узлов и value.
Цвет узлов назначается последовательно из набора доступных. Вышеописанные расчеты можно выполнить следующей функцией:
function calcMetaData(dataRootNode, rootNodeWidth) {
var startWidth = rootNodeWidth,
meta = {
root: {
data: dataRootNode,
color: pickColor(),
angles: {begin: 0, end: 2 * Math.PI, abs: 2 * Math.PI}, // корневой узел - круг
width: startWidth,
offset: 0,
children: [],
scale: 1
}
},
sibling;
function calcChildMetaData(childDatum, parentMeta, sibling, scale) {
var meta = {
data: childDatum,
color: pickColor(),
parent: parentMeta,
width: parentMeta.width / scale,
offset: parentMeta.offset + parentMeta.width,
children: [],
scale: parentMeta.scale / scale
}, childSibling;
meta.angles = {abs: parentMeta.angles.abs * childDatum.value / parentMeta.data.value};
meta.angles.begin = sibling ? sibling.angles.end : parentMeta.angles.begin;
meta.angles.end = meta.angles.begin + meta.angles.abs;
for (var i = 0, l = (childDatum.children || []).length; i < l; i++) {
childSibling = calcChildMetaData(childDatum.children[i], meta, childSibling, scale);
meta.children.push(childSibling);
}
return meta;
}
for (var i = 0, l = (dataRootNode.children || []).length; i < l; i++) {
if (dataRootNode.children[i].value > dataRootNode.value) {
console.error('Child value greater than parent value.', dataRootNode.children[i], dataRootNode);
continue;
}
sibling = calcChildMetaData(dataRootNode.children[i], meta.root, sibling, 1.62);
meta.root.children.push(sibling);
}
return meta;
};
Отрисовать центральный узел проще всего. Для этого нужно сделать замкнутую дугу, испольуя функцию arc(), а затем залить ее цветом.
var nodeMeta = {width: 20px, color: 'green', angles: {begin: 0, end: 2 * Math.PI}}; // параметры визуализации узла
var origin = {x: 250, y: 250}; // центр холста
var ctx = canvas.getContext('2d');
function drawRootNodeBody(nodeMeta, origin, ctx) {
ctx.beginPath();
ctx.arc(origin.x, origin.y, nodeMeta.width, nodeMeta.angles.begin, nodeMeta.angles.end); // окружность
ctx.fillStyle = nodeMeta.color; // рисуем круг - заполняем окружность цветом
ctx.fill();
ctx.strokeStyle = 'white'; // рисуем границу узла - белого цвета
ctx.stroke();
}
Остальные узлы рисовать несколько интереснее. Фактически нужно нарисовать замкнутый путь требуемой формы, а затем залить его цветом.
Путь на canvas, формирующий узел.
Последовательность рисования участков указан стрелками. С какой из сторон начинать отрисовку значения не имеет. Мы начнем с внешней дуги. Функция отрисовки нецентральных узлов:
function drawChildNodeBody(nodeMeta, origin, ctx) {
ctx.beginPath();
ctx.arc(origin.x, origin.y, nodeMeta.offset, nodeMeta.angles.begin, nodeMeta.angles.end); // внешняя дуга
// нижняя боковая грань
ctx.save();
ctx.translate(origin.x, origin.y); // перенос начала системы координат холста в центр диаграммы
ctx.rotate(nodeMeta.angles.end); // поворот холста, чтобы можно было провести
// прямую линию от крайней точки дуги к центру диаграммы
ctx.lineTo(nodeMeta.offset + nodeMeta.width, 0);
ctx.restore(); // восстанавливаем начало системы координат и угол холста
// внутренняя дуга
ctx.arc(origin.x, origin.y, nodeMeta.offset + nodeMeta.width,
nodeMeta.angles.end, nodeMeta.angles.begin, true);
// замыкаем внутреннюю и внешнюю дуги - фактическ верхняя боковая грань.
ctx.closePath();
ctx.fillStyle = nodeMeta.hover ? 'red' : nodeMeta.color;
ctx.fill();
ctx.strokeStyle = 'white';
ctx.stroke();
}
Вместо поворота контекста для отрисовки одной из боковых дуг, можно было бы воспользоваться Math.sin() (или Math.cos()) угла между вертикалью (или горизонталью) и углом поворота боковой составляющей узла. Правда благодаря повороту холста код значительно упрощается. Интересно, как этот момент влияет на производительность рендеринга.
Определение узла диаграммы по заданным координатам
Чтобы в дальнейшем реализовать масштабирование диаграммы кликом (или тачем) и hover узлов, необходимо научиться определять узел диаграммы по координатам на холсте. Это легко сделать, использую переход от декартовой к полярной системе координат.
Для начала определим расстояние от центра диаграммы (вычислен еще до начала рендеринга) до точки клика (координаты известны из объекта события onclick) и угол между осью X и отрезком, соединяющим центр диаграммы и эту точку.
Для этого нам понадобится следующая функция:
function cartesianCoordsToPolarCoords(point, origin) {
var difX = point.x - origin.x,
difY = point.y - origin.y,
distance = Math.sqrt(difX * difX + difY * difY),
angle = Math.acos(difX / distance);
if (difY < 0) {
angle = 2 * Math.PI - angle;
}
return {dist: distance, angle: angle};
};
Теперь, если вспомнить, что мы предварительно сделали расчеты некоторых метаданных о каждом узле, и они включают в себя ширину уровня, угол начала и протяженность каждой из дуг узлов, можно очень простым способом найти узел под указателем по его полярным координатам:
function getNodeByPolarCoords(point, origin, metaData) {
function _findNode(point, nodeMeta) {
// Для начала проверяем текущий узел
if (nodeMeta.offset >= point.dist) {
// Если смещение его уровня относительно центра больше, чем distance,
// то ни он, ни его наследники не могут быть нашей целью.
return null;
}
if (nodeMeta.offset < point.dist && point.dist <= nodeMeta.offset + nodeMeta.width) {
// Нашли уровень, теперь ищем узел в нем по углу.
if (nodeMeta.angles.begin < point.angle && point.angle <= nodeMeta.angles.end) {
return nodeMeta;
}
} else {
// We need to go deeper. Поиск в наследниках текущего узла.
var node;
for (var i = 0, l = (nodeMeta.children || []).length; i < l; i++) {
if (node = _findNode(point, nodeMeta.children[i])) {
return node;
}
}
}
return null;
}
return _findNode(point, metaData.root);
};
Изменение детализации диаграммы
Для более детального рассмотрения узлов, расположенных по краям диаграммы, необходимо реализовать масштабирование кликом по узлу. На самом деле этот сценарий эквивалентен повторной отрисовке диаграммы, но в качесте корневого узла данных должен быть выбран тот, по которому был осуществлен клик. Аналогично, при клике по центральному узлу достаточно найти его предка в структуре данных, и, если он существует, отрисовать диаграмму, выбрав в качесте корневого узла последнего. Реиспользование кода на 100%.
Заключение
Реализовать графики на js + canvas на самом деле оказалось не такой уж и сложной задачей. Достаточно немного порисовать теплым ламповым карандашом на бумаге системы координат и вспомнить определение sin и cos из школьного курса.
Рабочий пример можно посмотреть на github.io.
Код доступен в репозитории на github.
Автор: Ostrovski