Как сделать 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. Он стремится минимизировать у каждого блока отношение его длины к ширине, чтобы блоки становились более квадратными.
Диагональная структура возникает из-за способа, которым алгоритм размещает блоки, пытаясь оптимизировать использование пространства. При добавлении каждого нового блока алгоритм оценивает, как это повлияет на общее отношение размеров всех блоков в ряду. Когда очередное добавление начинает существенно ухудшать это отношение, начинается новый ряд. При этом горизонтальные и вертикальные блоки чередуются.
Захотелось даже как-нибудь потом реализовать все эти основные алгоритмы и сравнить.