Waterfall-диаграмма

Waterfall-диаграмма (каскадная диаграмма) используется для отображения последовательного накопления изменений, влияющих на итоговое значение. В контексте D3.js такая визуализация строится на основе прямоугольников, где каждый элемент представляет либо прирост, либо убывание относительно предыдущего состояния.

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

Основные компоненты:

  • начальное значение (baseline)
  • последовательность изменений (positive/negative deltas)
  • итоговое значение (total)

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

Типичная структура данных для waterfall:

const data = [
  { name: "Start", value: 1000 },
  { name: "Revenue", value: 300 },
  { name: "Cost", value: -200 },
  { name: "Marketing", value: -150 },
  { name: "Profit", value: 0 }
];

Последний элемент часто используется как итоговый контейнер, вычисляемый на основе предыдущих значений.


Вычисление промежуточных уровней

Перед построением графика требуется преобразовать исходные значения в структуру с координатами:

let cumulative = 0;

const processed = data.map((d, i) => {
  const start = cumulative;
  cumulative += d.value;
  const end = cumulative;

  return {
    name: d.name,
    value: d.value,
    start,
    end,
    isNegative: d.value < 0
  };
});

Так формируются два ключевых параметра:

  • start — нижняя граница столбца
  • end — верхняя граница столбца

Создание SVG-контейнера

Базовая структура SVG:

const margin = { top: 20, right: 30, bottom: 40, left: 60 };

const width = 900 - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;

const svg = d3.select("body")
  .append("svg")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

Оси координат

Ось X (категории)

const x = d3.scaleBand()
  .domain(processed.map(d => d.name))
  .range([0, width])
  .padding(0.3);

Ось Y (значения)

const y = d3.scaleLinear()
  .domain([
    d3.min(processed, d => Math.min(d.start, d.end)),
    d3.max(processed, d => Math.max(d.start, d.end))
  ])
  .nice()
  .range([height, 0]);

Добавление осей:

svg.append("g")
  .attr("transform", `translate(0,${height})`)
  .call(d3.axisBottom(x));

svg.append("g")
  .call(d3.axisLeft(y));

Построение столбцов waterfall

Каждый прямоугольник формируется на основе start и end:

svg.selectAll(".bar")
  .data(processed)
  .enter()
  .append("rect")
  .attr("class", "bar")
  .attr("x", d => x(d.name))
  .attr("y", d => y(Math.max(d.start, d.end)))
  .attr("height", d => Math.abs(y(d.start) - y(d.end)))
  .attr("width", x.bandwidth())
  .attr("fill", d => d.value >= 0 ? "#4caf50" : "#f44336");

Соединительные линии между столбцами

Для усиления восприятия каскадности добавляются линии переходов:

svg.selectAll(".connector")
  .data(processed.slice(0, -1))
  .enter()
  .append("line")
  .attr("x1", d => x(d.name) + x.bandwidth())
  .attr("y1", d => y(d.end))
  .attr("x2", (d, i) => x(processed[i + 1].name))
  .attr("y2", (d, i) => y(d.end))
  .attr("stroke", "#999")
  .attr("stroke-dasharray", "3,3");

Выделение стартовой и итоговой колонок

Часто требуется визуально отделять базовое и итоговое значения:

svg.selectAll(".bar")
  .attr("fill", (d, i) => {
    if (i === 0) return "#2196f3";
    if (i === processed.length - 1) return "#673ab7";
    return d.value >= 0 ? "#4caf50" : "#f44336";
  });

Подписи значений

Добавление текстовых меток повышает читаемость:

svg.selectAll(".label")
  .data(processed)
  .enter()
  .append("text")
  .attr("x", d => x(d.name) + x.bandwidth() / 2)
  .attr("y", d => y(Math.max(d.start, d.end)) - 5)
  .attr("text-anchor", "middle")
  .text(d => d.value);

Анимация построения

Плавное появление столбцов:

svg.selectAll("rect")
  .attr("y", height)
  .attr("height", 0)
  .transition()
  .duration(800)
  .delay((d, i) => i * 150)
  .attr("y", d => y(Math.max(d.start, d.end)))
  .attr("height", d => Math.abs(y(d.start) - y(d.end)));

Обработка итогового значения

Итоговая колонка часто рассчитывается автоматически:

const total = data.slice(1, -1).reduce((acc, d) => acc + d.value, data[0].value);

processed[processed.length - 1].value = total;
processed[processed.length - 1].start = 0;
processed[processed.length - 1].end = total;

Стилизация через CSS

.bar {
  stroke: #222;
  stroke-width: 1px;
}

text {
  font-size: 12px;
  fill: #333;
}

line {
  stroke-opacity: 0.6;
}

Масштабирование при большом наборе данных

При увеличении количества категорий используется адаптивное масштабирование:

x.range([0, width])
  .paddingInner(0.2)
  .paddingOuter(0.1);

Для перегруженных диаграмм применяется горизонтальный скролл через увеличение ширины SVG:

const width = data.length * 80;

Интерактивность

Добавление hover-эффектов:

svg.selectAll("rect")
  .on("mouseover", function () {
    d3.select(this)
      .attr("opacity", 0.7);
  })
  .on("mouseout", function () {
    d3.select(this)
      .attr("opacity", 1);
  });

Логика отрицательных и положительных значений

Ключевой механизм waterfall заключается в разделении направлений изменений:

  • положительные значения смещают итог вверх
  • отрицательные значения смещают итог вниз

Геометрическая интерпретация задаётся через:

Math.max(start, end)
Math.abs(start - end)

Компоновка итоговой визуальной структуры

Структура диаграммы в DOM:

svg
 ├── g (оси)
 ├── rect (столбцы)
 ├── line (связи)
 └── text (подписи)

Каждый слой отвечает за отдельный аспект визуализации: координаты, значения, связи и аннотации.


Обработка обновления данных

При динамическом изменении набора:

function update(newData) {
  const updated = compute(newData);

  svg.selectAll("rect")
    .data(updated)
    .join("rect")
    .transition()
    .duration(600)
    .attr("y", d => y(Math.max(d.start, d.end)))
    .attr("height", d => Math.abs(y(d.start) - y(d.end)));
}

Устойчивость к неоднородным данным

При наличии пропусков или нулевых значений применяется нормализация:

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

Дополнительно вводится контроль диапазона осей:

y.domain([
  Math.min(0, d3.min(processed, d => d.start)),
  d3.max(processed, d => d.end)
]);