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