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

в 15:02, , рубрики: canvas, charts, javascipt, javascript, Веб-разработка, визуализация данных

Реализация Sunburst Chart на JavaScript и HTML5 Canvas - 1
Всем привет! Сегодня хотелось бы рассказать про то, как можно сделать собственные графики на 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). Отобразить их нужно в нечто подобное:

Реализация Sunburst Chart на JavaScript и HTML5 Canvas - 2

При этом необходимо предусмотреть возможность масштабирования диаграммы кликом на различные области, т.е. фактически переход по дереву данных. А также редактирование данных, т.е. добавление и удаление узлов.

Возможности

  • Данные должны относительно равномерно располагаться на холсте, с соблюдением пропорций — чем дальше узел находится от центра, тем меньше места он должен занимать.
  • Клик по узлу, не являющемуся в данный момент корневым, должен приводить к увеличению детализации диаграммы.
  • Клик по центральному узлу должен приводить к уменьшению детализации диаграммы.
  • Необходима реакция диаграммы при наведении курсора и два режима выделения узлов: отдельный узел и путь от центра диаграммы к выделенному узлу.

В результате хотелось бы получить некоторый гибрид этого и этого.

Реализация

Расположение данных

Для того, чтобы равномерно распределить данные по холсту, необходимо для начала выяснить их максимальную вложенность. Это достаточно легко сделать с помощью следующей рекурсивной функции:

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 радиан) и они сливаются в одну линию (мы ее не визуализируем). Толщина центрального узла является радиусом этого круга.

Все последующие узлы являются дугами, обернутыми вокруг центрального узла.

Реализация Sunburst Chart на JavaScript и HTML5 Canvas - 3
Узлы первого (не центрального) уровня.

Длина дуги (т.е. угол между ее боковыми составляющими) вычисляется исходя из соотношения величины данных (value), которые соответстуют этой дуге и величины данных родительского для этой дуги узла. Таким образом, если центральный узел обладает значением value = 100, а вложенный в него узел первого уровня обладает value = 50, то угол последнего будет составлять 180 градусов (50 / 100 = Pi / 2 Pi). Это правило рекурсивно применяется для каждого из узлов по отношению к его родителю. Если узел имеет 2 и более наследников, то максимальный угол его первого наследника будет являться минимальным углом второго и так далее. Все расчеты идут по часовой стрелке.

Реализация Sunburst Chart на JavaScript и HTML5 Canvas - 4
Соотношение длин дуг узлов и 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();
}

Остальные узлы рисовать несколько интереснее. Фактически нужно нарисовать замкнутый путь требуемой формы, а затем залить его цветом.

Реализация Sunburst Chart на JavaScript и HTML5 Canvas - 5
Путь на 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 и отрезком, соединяющим центр диаграммы и эту точку.

Реализация Sunburst Chart на JavaScript и HTML5 Canvas - 6

Для этого нам понадобится следующая функция:

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js