Сводные таблицы и трансформации данных

При построении визуализаций данные редко используются в исходном виде. Перед отображением массивы объектов обычно проходят этап агрегации, группировки, фильтрации и вычисления производных значений. В D3.js подобные операции выполняются средствами модуля d3-array.

Сводные таблицы позволяют:

  • объединять записи по категориям;
  • вычислять статистику;
  • преобразовывать плоские массивы в иерархические структуры;
  • готовить данные для диаграмм;
  • выполнять промежуточные вычисления без сторонних библиотек.

Большинство операций строится вокруг:

  • d3.group()
  • d3.rollup()
  • d3.index()
  • d3.flatGroup()
  • d3.flatRollup()
  • d3.sort()
  • d3.filter()
  • d3.map()
  • d3.reduce()

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

Исходный набор данных:

const sales = [
  { city: "Москва", category: "Ноутбуки", amount: 120000 },
  { city: "Москва", category: "Смартфоны", amount: 80000 },
  { city: "Казань", category: "Ноутбуки", amount: 50000 },
  { city: "Казань", category: "Смартфоны", amount: 40000 },
  { city: "Москва", category: "Ноутбуки", amount: 70000 }
];

Каждый объект представляет отдельную запись продаж.


Группировка данных через d3.group()

Функция d3.group() создаёт структуру Map, где ключом выступает результат функции-группировщика.

Простая группировка

const grouped = d3.group(sales, d => d.city);

Результат:

Map {
  "Москва" => [...],
  "Казань" => [...]
}

Получение данных конкретной группы:

console.log(grouped.get("Москва"));

Многоуровневая группировка

d3.group() поддерживает неограниченное количество уровней.

const grouped = d3.group(
  sales,
  d => d.city,
  d => d.category
);

Структура:

Map {
  "Москва" => Map {
    "Ноутбуки" => [...],
    "Смартфоны" => [...]
  },

  "Казань" => Map {
    "Ноутбуки" => [...],
    "Смартфоны" => [...]
  }
}

Доступ к вложенным данным:

grouped
  .get("Москва")
  .get("Ноутбуки");

Преобразование Map в массив

Многие визуализации работают именно с массивами.

Использование Array.from()

const result = Array.from(grouped, ([key, values]) => {
  return {
    city: key,
    values: values
  };
});

Результат:

[
  {
    city: "Москва",
    values: [...]
  },
  {
    city: "Казань",
    values: [...]
  }
]

Использование d3.groups()

d3.groups() возвращает обычный массив вместо Map.

const groups = d3.groups(sales, d => d.city);

Результат:

[
  [
    "Москва",
    [...]
  ],
  [
    "Казань",
    [...]
  ]
]

Преимущество:

  • удобнее сериализовать;
  • проще использовать в React/Vue;
  • легче передавать в JSON.

Агрегация данных через d3.rollup()

d3.rollup() выполняет группировку и одновременно вычисляет итоговое значение.

Сумма продаж по городам

const totals = d3.rollup(
  sales,
  v => d3.sum(v, d => d.amount),
  d => d.city
);

Результат:

Map {
  "Москва" => 270000,
  "Казань" => 90000
}

Средние значения

const averages = d3.rollup(
  sales,
  v => d3.mean(v, d => d.amount),
  d => d.city
);

Максимальные значения

const maxValues = d3.rollup(
  sales,
  v => d3.max(v, d => d.amount),
  d => d.city
);

Несколько вычислений одновременно

Часто требуется сохранить несколько статистических показателей.

const stats = d3.rollup(
  sales,
  v => ({
    total: d3.sum(v, d => d.amount),
    average: d3.mean(v, d => d.amount),
    max: d3.max(v, d => d.amount),
    count: v.length
  }),
  d => d.city
);

Результат:

Map {
  "Москва" => {
    total: 270000,
    average: 90000,
    max: 120000,
    count: 3
  }
}

Использование d3.rollups()

d3.rollups() аналогичен d3.rollup(), но возвращает массив.

const result = d3.rollups(
  sales,
  v => d3.sum(v, d => d.amount),
  d => d.city
);

Результат:

[
  ["Москва", 270000],
  ["Казань", 90000]
]

Плоские структуры через d3.flatGroup()

При глубокой вложенности Map становится неудобным. Для этого используется плоское представление.

const flat = d3.flatGroup(
  sales,
  d => d.city,
  d => d.category
);

Результат:

[
  ["Москва", "Ноутбуки", [...]],
  ["Москва", "Смартфоны", [...]],
  ["Казань", "Ноутбуки", [...]]
]

Агрегация в плоском формате

const flatRollup = d3.flatRollup(
  sales,
  v => d3.sum(v, d => d.amount),
  d => d.city,
  d => d.category
);

Результат:

[
  ["Москва", "Ноутбуки", 190000],
  ["Москва", "Смартфоны", 80000],
  ["Казань", "Ноутбуки", 50000]
]

Такой формат особенно полезен для:

  • тепловых карт;
  • stacked chart;
  • Sankey-диаграмм;
  • экспорта CSV.

Индексация данных через d3.index()

d3.index() создаёт карту уникальных записей.

const index = d3.index(
  sales,
  d => d.city
);

Если ключи не уникальны, возникнет ошибка.

Обычно используется для быстрого доступа:

const cityData = index.get("Москва");

Многоуровневый индекс

const nestedIndex = d3.index(
  sales,
  d => d.city,
  d => d.category
);

Сортировка данных

Стандартная сортировка

const sorted = sales.sort(
  (a, b) => b.amount - a.amount
);

Использование d3.sort()

d3.sort() не изменяет исходный массив.

const sorted = d3.sort(
  sales,
  d => d.amount
);

Сортировка по убыванию

const sorted = d3.sort(
  sales,
  (a, b) => d3.descending(a.amount, b.amount)
);

Сортировка строк

const sorted = d3.sort(
  sales,
  d => d.city
);

Фильтрация данных

Простая фильтрация

const filtered = sales.filter(
  d => d.amount > 60000
);

Фильтрация через d3.filter()

В новых версиях D3 чаще используется нативный Array.filter(), однако D3 хорошо интегрируется с итераторами.


Преобразование данных

Использование map()

const transformed = sales.map(d => ({
  ...d,
  amountK: d.amount / 1000
}));

Создание новых полей

const prepared = sales.map(d => ({
  city: d.city,
  category: d.category,
  amount: d.amount,
  tax: d.amount * 0.2
}));

Reduce и вычисление итогов

Общая сумма

const total = sales.reduce(
  (sum, d) => sum + d.amount,
  0
);

Подсчёт количества элементов

const count = sales.reduce(
  (acc) => acc + 1,
  0
);

Использование d3.sum()

const total = d3.sum(
  sales,
  d => d.amount
);

Использование d3.mean()

const average = d3.mean(
  sales,
  d => d.amount
);

Использование d3.median()

const median = d3.median(
  sales,
  d => d.amount
);

Использование d3.extent()

Функция возвращает минимальное и максимальное значение.

const extent = d3.extent(
  sales,
  d => d.amount
);

Результат:

[40000, 120000]

Использование d3.max() и d3.min()

const max = d3.max(
  sales,
  d => d.amount
);

const min = d3.min(
  sales,
  d => d.amount
);

Построение структуры для stacked chart

Исходные данные:

const data = [
  { month: "Январь", phones: 30, laptops: 20 },
  { month: "Февраль", phones: 40, laptops: 15 }
];

Создание стека:

const stack = d3.stack()
  .keys(["phones", "laptops"]);

const series = stack(data);

Результат:

[
  [
    [0, 30],
    [0, 40]
  ],
  [
    [30, 50],
    [40, 55]
  ]
]

Подготовка данных для pie chart

const pie = d3.pie()
  .value(d => d.amount);

const arcs = pie(sales);

Каждый элемент получает:

  • стартовый угол;
  • конечный угол;
  • значение;
  • индекс.

Использование d3.bin()

d3.bin() используется для группировки чисел по диапазонам.

Построение корзин

const values = [10, 20, 25, 30, 35, 40, 45];

const bins = d3.bin()(values);

Настройка количества корзин

const histogram = d3.bin()
  .thresholds(5);

const bins = histogram(values);

Настройка диапазонов

const histogram = d3.bin()
  .domain([0, 100])
  .thresholds([20, 40, 60, 80]);

Использование d3.cross()

Создание декартового произведения:

const result = d3.cross(
  ["Москва", "Казань"],
  ["Ноутбуки", "Смартфоны"]
);

Результат:

[
  ["Москва", "Ноутбуки"],
  ["Москва", "Смартфоны"],
  ["Казань", "Ноутбуки"],
  ["Казань", "Смартфоны"]
]

Использование d3.merge()

Объединение вложенных массивов:

const arrays = [
  [1, 2],
  [3, 4],
  [5, 6]
];

const merged = d3.merge(arrays);

Использование d3.zip()

const names = ["A", "B", "C"];
const values = [10, 20, 30];

const zipped = d3.zip(names, values);

Результат:

[
  ["A", 10],
  ["B", 20],
  ["C", 30]
]

Использование d3.transpose()

const matrix = [
  [1, 2, 3],
  [4, 5, 6]
];

const transposed = d3.transpose(matrix);

Результат:

[
  [1, 4],
  [2, 5],
  [3, 6]
]

Генерация уникальных ключей

При работе с визуализациями важно формировать стабильные идентификаторы.

const prepared = sales.map(d => ({
  id: `${d.city}-${d.category}`,
  ...d
}));

Нормализация данных

Исходные значения:

const values = [10, 50, 80];

Нормализация:

const max = d3.max(values);

const normalized = values.map(
  d => d / max
);

Кумулятивные суммы

let sum = 0;

const cumulative = values.map(v => {
  sum += v;
  return sum;
});

Подготовка данных для временных рядов

const parseDate = d3.timeParse("%Y-%m-%d");

const data = raw.map(d => ({
  date: parseDate(d.date),
  value: +d.value
}));

Группировка по датам

const grouped = d3.rollup(
  data,
  v => d3.sum(v, d => d.value),
  d => d3.timeMonth(d.date)
);

Работа с пропущенными значениями

Фильтрация null

const cleaned = data.filter(
  d => d.value != null
);

Замена пустых значений

const prepared = data.map(d => ({
  ...d,
  value: d.value ?? 0
}));

Создание структуры для heatmap

const heatmap = d3.flatRollup(
  sales,
  v => d3.sum(v, d => d.amount),
  d => d.city,
  d => d.category
).map(([city, category, value]) => ({
  city,
  category,
  value
}));

Подготовка данных для force graph

const nodes = [
  { id: "A" },
  { id: "B" }
];

const links = [
  { source: "A", target: "B" }
];

Использование d3.hierarchy()

Преобразование плоских данных в иерархию:

const hierarchy = d3.hierarchy(data);

Суммирование значений в иерархии

hierarchy.sum(d => d.value);

Сортировка узлов иерархии

hierarchy.sort(
  (a, b) => b.value - a.value
);

Практический пример полной трансформации

Исходные данные:

const raw = [
  { region: "Север", sales: 100 },
  { region: "Юг", sales: 200 },
  { region: "Север", sales: 150 }
];

Полная обработка:

const result = d3.rollups(
  raw,
  values => ({
    total: d3.sum(values, d => d.sales),
    average: d3.mean(values, d => d.sales)
  }),
  d => d.region
).map(([region, stats]) => ({
  region,
  ...stats
}));

Результат:

[
  {
    region: "Север",
    total: 250,
    average: 125
  },
  {
    region: "Юг",
    total: 200,
    average: 200
  }
]

Оптимизация трансформаций

Избегание повторных вычислений

Плохо:

data.map(d => ({
  total: expensiveCalculation(d)
}));

Хорошо:

const cache = new Map();

const prepared = data.map(d => {
  if (!cache.has(d.id)) {
    cache.set(d.id, expensiveCalculation(d));
  }

  return {
    ...d,
    total: cache.get(d.id)
  };
});

Иммутабельные преобразования

D3 хорошо сочетается с функциональным стилем.

const updated = data.map(d => ({
  ...d,
  active: true
}));

Исходный массив не изменяется.


Цепочки преобразований

const result = sales
  .filter(d => d.amount > 50000)
  .map(d => ({
    ...d,
    tax: d.amount * 0.2
  }))
  .sort((a, b) => b.amount - a.amount);

Подготовка данных для enter/update/exit

Ключи должны быть стабильными:

svg.selectAll("rect")
  .data(data, d => d.id);

При отсутствии ключей D3 может:

  • переиспользовать неправильные элементы;
  • ломать анимации;
  • создавать лишние DOM-узлы.

Частые ошибки при трансформациях данных

Изменение исходного массива

Плохо:

data.sort(...)

Лучше:

const sorted = d3.sort(data, ...);

Смешивание типов

Проблема:

{ value: "100" }

Решение:

{ value: +d.value }

Работа с undefined

Плохо:

d.amount.toFixed(2)

Безопаснее:

(d.amount ?? 0).toFixed(2)

Архитектура подготовки данных

Крупные проекты обычно разделяют:

  1. загрузку данных;
  2. очистку;
  3. агрегацию;
  4. вычисления;
  5. подготовку структуры;
  6. визуализацию.

Пример:

const raw = await d3.csv("sales.csv");

const cleaned = cleanData(raw);

const aggregated = aggregate(cleaned);

const chartData = prepareChartData(aggregated);

renderChart(chartData);

Подобная архитектура упрощает:

  • поддержку проекта;
  • повторное использование логики;
  • тестирование;
  • масштабирование визуализаций.