d3js
January 17

Как сделать Treemap график на D3.js

Сегодня я решила написать про создание графика Тримап — одного из самых мощных инструментов для визуализации иерархических данных.

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

В этой статье я расскажу, как создать базовый Тримап с помощью D3.js и дам советы, как сделать его более информативным и визуально привлекательным.

По традиции сразу делюсь ссылками на результат:

  1. Интерактивный Codepen
  2. Код на Github

Подготовка данных

Каждый отдельный элемент Тримапа — это объект со следующими параметрами:

{
  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);

И результат чаще всего — вылезающие за пределы подписи, как на картинке.

Сейчас расскажу, как можно это исправить. Основных варианта три:

  1. Прятать вылезающую часть подписи, имитируя свойство ellipsis.
  2. Адаптировать подписи, например, менять их размер чтобы они полностью помещались.
  3. Использовать 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, можно сделать и многострочный текст, и ссылки, и многое другое.

В своём примере я оставлю метод с вариативными размерами.

Ссылки

Так у меня получился базовый Тримап. Его есть, куда улучшать и усложнять, но это уже тема для отдельной статьи.

  1. Пример на Codepen
  2. Код на Github
  3. Получившийся график

Вместо заключения

Когда-то давно я делала небольшой эксперимент на D3, генерируемый Тримап в стиле Пита Мондриана, и заметила, что он всегда имеет частично диагональную структуру.

Например, тут все элементы имеют одинаковые значения и диагональная
структура явно видна в правой «квадратной» части.

Мне стало интересно, почему так происходит.

Оказалось, что существует 15 первичных алгоритмов построения Тримапа. D3 по умолчанию использует один из них, Squarified. Он стремится минимизировать у каждого блока отношение его длины к ширине, чтобы блоки становились более квадратными.

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

Захотелось даже как-нибудь потом реализовать все эти основные алгоритмы и сравнить.