Waterfall-диаграмма (каскадная диаграмма) используется для отображения последовательного накопления изменений, влияющих на итоговое значение. В контексте D3.js такая визуализация строится на основе прямоугольников, где каждый элемент представляет либо прирост, либо убывание относительно предыдущего состояния.
Ключевая особенность структуры заключается в том, что каждая следующая колонка опирается на результат предыдущей, формируя цепочку промежуточных сумм.
Основные компоненты:
Типичная структура данных для 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
};
});
Так формируются два ключевых параметра:
Базовая структура 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})`);
const x = d3.scaleBand()
.domain(processed.map(d => d.name))
.range([0, width])
.padding(0.3);
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));
Каждый прямоугольник формируется на основе 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;
.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)
]);