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