Как сделать Treemap график на D3.js
Сегодня я решила написать про создание графика Тримап — одного из самых мощных инструментов для визуализации иерархических данных.
Тримап позволяет отобразить сложные вложенные структуры в компактной и интуитивно понятной форме и идеально подходит для визуализаций, где каждый элемент является частью большего целого. Это, например, может быть визуализация долей рынка компаний в определенной отрасли, распределение расходов в бюджете или демонстрация различных видов в естественной иерархии.
В этой статье я расскажу, как создать базовый Тримап с помощью D3.js и дам советы, как сделать его более информативным и визуально привлекательным.
По традиции сразу делюсь ссылками на результат:
Подготовка данных
Каждый отдельный элемент Тримапа — это объект со следующими параметрами:
{
name: <string>,
value: <integer>
}Тримап — это много таких объектов, собранных в единую иерархическую структуру. Вот как может выглядеть иерархия с двумя уровнями вложенности:
{
name: "treemap",
children: [
{
name: "group1",
children: [
{ name: "element1", value: 20 },
{ name: "element2", value: 50 },
{ name: "element3", value: 70 },
]
},
{
name: "group2",
children: [
{ name: "element4", value: 10 },
{ name: "element5", value: 60 },
]
}
]
}В этот раз я создам данные случайным образом. Пусть у меня будет как в примере выше два уровня вложенности: Тримап разбит на группы, группы — на элементы.
Следующий код создаёт случайное количество групп (от 3 до 5) и случайное количество элементов в каждой группе (от 5 до 10). При этом значения этих элементов могут варьироваться от 20 до 100.
const data = { name: "treemap", children: [] };
const groupsCount = Math.round(3 + Math.random() * 2);
for (let i = 0; i < groupsCount; i++) {
const group = { name: `group_${i + 1}`, children: [] };
const count = Math.round(5 + Math.random() * 5);
for (let j = 0; j < count; j++) {
group.children.push({
name: `element_${j + 1}`,
value: 20 + Math.random() * 80,
});
}
data.children.push(group);
}Подготовка графика
Я, как и всегда раньше, буду писать всё в одном файле. Cтруктурe этого файла, подключение библиотеки и настройки главного SVG элемента можно подсмотреть в первой статье моего цикла (Как сделать Line Chart) или в коде примера.
Я создала данные нужного D3 формата, но перед рисованием их ещё нужно обработать. Сделать это можно с помощью методов d3.hierarchy и d3.treemap:
const root = d3.hierarchy(data).sum((d) => d.value); d3.treemap().size([width, height]).padding(2)(root);
Сначала hierarchy суммирует значения на каждом уровне, а потом treemap вычисляет размеры и позиции всех элементов. В итоге получается большой и сложный объект как на картинке ниже:
Хорошо, данные готовы. Кроме них я сделаю простую палитру, чтобы красить разные группы в разные цвета:
const color = d3.scaleOrdinal(d3.schemeCategory10);
Рисование прямоугольников и подписей
Теперь можно рисовать SVG элементы. Сначала прямоугольники:
svg
.selectAll('rect')
.data(root.leaves())
.join('rect')
.attr('x', (d) => d.x0)
.attr('y', (d) => d.y0)
.attr('width', (d) => d.x1 - d.x0)
.attr('height', (d) => d.y1 - d.y0)
.style('stroke', 'black')
.style('fill', (d) => color(d.parent.data.name));Это все элементарные частицы Тримапа, которые можно получить с помощью root.leaves(). Их позиции и размеры уже вычислены, а цвет мы берём из палитры по имени родителя-группы.
Второй шаг — отображение подписей. И тут сейчас будут нюансы, но сначала сделаем подписи простыми текстовыми элементами:
svg
.selectAll('text')
.data(root.leaves())
.join('text')
.attr('x', (d) => d.x0 + padding)
.attr('y', (d) => d.y0 + fontSize)
.style('font-size', `${fontSize}px`)
.text((d) => d.data.name);И результат чаще всего — вылезающие за пределы подписи, как на картинке.
Сейчас расскажу, как можно это исправить. Основных варианта три:
- Прятать вылезающую часть подписи, имитируя свойство ellipsis.
- Адаптировать подписи, например, менять их размер чтобы они полностью помещались.
- Использовать DOM элементы и сделать любую более сложную логику.
Обрезание подписей
Это самый простой способ и я обычно пользуюсь именно им. Суть в том, чтобы вычислить ширину блока, длину текста и обрезать то, что не влезло, заменив на «...». Ширина блока нам всегда известна, это x1 - x0, а вот длину текста можно считать по-разному.
Я часто пользуюсь простым лайфхаком: среднюю ширину одной буквы можно привязать к размеру шрифта и через эту привязку вычислять длину текста как text.length * fontSize * ratio. Троеточие вместо последних букв тоже, конечно, займёт место, поэтому пусть его длина это примерно один fontSize.
.text((d) => {
const ratio = 0.5;
const text = d.data.name;
const width = d.x1 - d.x0 - padding * 2;
if (width > text.length * ratio * fontSize) {
return text;
}
const length = Math.max(width / (ratio * fontSize) - 1, 0);
const result = d.data.name.substring(0, length);
return `${result}...`;
});Это решение простое, но не вариативное. Что делать, если размер шрифта или сам шрифт меняется? Если подписи очень длинные? А если они не помещаются по высоте?
Адаптация подписей
Тогда на помощь могут прийти другие способы.
Например, можно точно вычислять длину текста. Это делается через невидимый вспомогательный canvas элемент и метод measureText его контекста.
Усложнением этого метода может быть и изменение размера подписей. Например, мой базовый размер — 16 пикселей. Я могу пытаться вписать текст в блок, постепенно уменьшая его размер, а уже потом обрезать непоместившееся:
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const measureText = (text, fontSize) => {
context.font = `${fontSize}px serif`;
return context.measureText(text).width;
};
const calculateTextSize = (d) => {
const text = d.data.name;
const width = d.x1 - d.x0 - padding * 2;
let size = fontSize;
let textWidth = measureText(text, size);
while (size > 10 && textWidth > width) {
size--;
textWidth = measureText(text, size);
}
return size;
};
/*
......
*/
.style('font-size', (d) => `${calculateTextSize(d)}px`)
.text((d) => {
const text = d.data.name;
const width = d.x1 - d.x0 - padding * 2;
const size = calculateTextSize(d);
if (measureText(text, size) <= width) return text;
let truncatedText = text;
textWidth = measureText(`${truncatedText}...`, size);
while (textWidth > width && truncatedText.length > 0) {
truncatedText = truncatedText.substring(0, truncatedText.length - 1);
textWidth = measureText(`${truncatedText}...`, size)
}
return `${truncatedText}...`;
});Использование DOM элементов
В SVG есть специальный тег, <foreignObject>, который используется, если нужно вставить любой чужеродный элемент. Раньше у этого тега была не очень хорошая поддержка, но сейчас ситуация стала сильно лучше и методом, который я сейчас опишу, уже вполне можно пользоваться.
Идея в том, чтобы вместо нативных для SVG тегов text использовать обычные div:
svg
.selectAll('foreignObject')
.data(root.leaves())
.join('foreignObject')
.attr('x', (d) => d.x0 + padding)
.attr('y', (d) => d.y0 + padding)
.attr('width', (d) => d.x1 - d.x0 - padding * 2)
.attr('height', (d) => d.y1 - d.y0 - padding * 2)
.append('xhtml:div')
.style('height', '100%')
.style('font-size', `${fontSize}px`)
.style('overflow', 'hidden')
.style('word-wrap', 'break-word')
.html((d) => d.data.name);Используя и стилизуя DOM элементы внутри foreignObject, можно сделать и многострочный текст, и ссылки, и многое другое.
В своём примере я оставлю метод с вариативными размерами.
Ссылки
Так у меня получился базовый Тримап. Его есть, куда улучшать и усложнять, но это уже тема для отдельной статьи.
Вместо заключения
Когда-то давно я делала небольшой эксперимент на D3, генерируемый Тримап в стиле Пита Мондриана, и заметила, что он всегда имеет частично диагональную структуру.
структура явно видна в правой «квадратной» части.
Мне стало интересно, почему так происходит.
Оказалось, что существует 15 первичных алгоритмов построения Тримапа. D3 по умолчанию использует один из них, Squarified. Он стремится минимизировать у каждого блока отношение его длины к ширине, чтобы блоки становились более квадратными.
Диагональная структура возникает из-за способа, которым алгоритм размещает блоки, пытаясь оптимизировать использование пространства. При добавлении каждого нового блока алгоритм оценивает, как это повлияет на общее отношение размеров всех блоков в ряду. Когда очередное добавление начинает существенно ухудшать это отношение, начинается новый ряд. При этом горизонтальные и вертикальные блоки чередуются.
Захотелось даже как-нибудь потом реализовать все эти основные алгоритмы и сравнить.