Реалізація Sunburst Chart на JavaScript і HTML5 Canvas


Всім привіт! Сьогодні хотілося б розповісти про те, як можна зробити власні графіки на 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.

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

0 коментарів

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