我是 D3.js 的新手,我正在尝试创建堆叠条形图,但我无法堆叠这些值。当在 .stack() 上调用
.value(([, group], key) => group.get(key).duration)
时,我得到 TypeError: Cannot read properties of undefined (reading 'duration')
。如何正确分组我的数据?
有了这些数据:
const transformedData = [
{ person: 'Frodo Baggins', project: 'Project a', duration: 21 },
{ person: 'Gandalf', project: 'Project a', duration: 32 },
{ person: 'Samwise Gamgee', project: 'Project f', duration: 88 },
{ person: 'Samwise Gamgee', project: 'Project g', duration: 27 },
{ person: 'Samwise Gamgee', project: 'Project d', duration: 148 },
{ person: 'Samwise Gamgee', project: 'Project h', duration: 34 },
]
我想分组,以便每个条形都显示每个人在一个项目上工作的时间量,累计到总时间中。
这是我的其余代码:
const margin = {top: 10, right: 30, bottom: 20, left: 50},
width = 460 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
const svg = d3.select(document.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 stackedData = d3.stack()
.keys(transformedData.map(d => d.person))
.value(([, group], key) => group.get(key).duration) #HERE IS WHERE THE ERROR OCCURS
(d3.index(transformedData, d => d.project, d => d.person));
const x = d3.scaleBand()
.domain(projects)
.range([0, width])
.padding([0.2])
svg.append("g")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x).tickSizeOuter(0));
const y = d3.scaleLinear()
.domain([0, totalDuration])
.range([ height, 0 ]);
svg.append("g")
.call(d3.axisLeft(y));
const color = d3.scaleOrdinal()
.domain(people)
.range(['#e41a1c','#377eb8','#4daf4a'])
svg.append("g")
.selectAll("g")
.data(stackedData)
.enter()
.append("g")
.attr("fill", d => color(d.key))
.selectAll("rect")
.data((d) => projects.map((project) => ({
project,
duration: transformedData.reduce((acc, i) => i.project === project && i.person === d.key ? acc + i.duration : acc, 0)
})))
.enter()
.append("rect")
.attr('x', (d, i) => (i * width) / projects.length)
.attr('y', (d) => height - (d.duration / totalDuration) * height)
.attr('height', (d) => (d.duration / totalDuration) * height)
.attr('width', width / projects.length - 2);
我尝试不分组,但最终将条形放置在彼此后面而不是顶部。
条形在彼此后面而不是在顶部:
问题的核心在于你的数据并不完整
Full Join
:不是每个人都在做每个项目。
代码
const projectToPersonMap = d3.index(transformedData, d => d.project, d => d.person);
将创建一个二维
map
,其中 project
和 person
作为键。
打电话
projectToPersonMap.get('Project a').get('Frodo Baggins')
将会产出
{"person": "Frodo Baggins","project": "Project a","duration": 21}
但是打电话
projectToPersonMap.get('Project a').get('Samwise Gamgee')
将是
undefined
。
d3.stack
将在 Full Join
和 projects
之间运行 persons
。你必须注意这种关系缺失的情况:
const stackedData = d3.stack()
// use unique people - do not repeat people in the keys array
.keys(d3.union(transformedData.map(d => d.person)))
.value(([project, group], key) => {
// in this case the project map might not have an entry for the given person
if (!group.has(key)) {
return 0; // i.e. a duration of 0
} else {
return group.get(key).duration;
}
})(projectToPersonMap);
生成的
stackedData
是一个如下所示的数组:
// a f g d h
[ 0,21], [ 0, 0], [0, 0], [0, 0], [0, 0] // Frodo Baggins
[21,53], [ 0, 0], [0, 0], [0, 0], [0, 0] // Gandalf
[53,53], [ 0,88], [0,27], [0,148], [0,34] // Samwise Gamgee
其中每个 2 元素数组表示
person
上 project
的堆叠持续时间。任何开始和结束持续时间相同的 2 元素数组(例如 [53,53]
代表 Samwise Gamgee ~ Project a)意味着此人没有在给定项目上工作(持续时间为 0)。
您可以使用
join()
创建您的 rect
元素。 但是,这将创建 rect
高度为 0 的元素。
另一种选择是将
filter()
与 enter()
和 append()
一起使用:
chartGroup
.selectAll('g.person')
.data(stackedData)
// this group contains all rects for a person
.join('g')
.attr('class', d => {
return `person ${encodeURI(d.key.replace(' ', '-').toLowerCase())}`;
})
// the color for that person
.attr('fill', d => color(d.key))
.selectAll('rect')
.data(d => d)
.enter()
//filter those rect with 0 height (missing person to project combination)
.filter(d => {
// check if the duration is > 0
return (d[1] - d[0]) > 0;
})
.append('rect')
.attr('class', d => {
return `project ${encodeURI(d.data[0].replace(' ', '-').toLowerCase())}`;
})
// the x position and width comes from the band scale
.attr('x', d => {
return x(d.data[0]);
})
.attr('width', x.bandwidth())
// add y position and height
.attr('y', d => y(d[1]))
.attr('height', d => (y(d[0]) - y(d[1])));
为了使用
d3.scaleLinear
创建 y 尺度,您还必须确定 最长项目的持续时间:
// use rollup to get the time spend on each project
const totalDurationPerProject = d3.rollup(
transformedData,
// sum up the duration for each project
arrayOfAllEntriesForProject => d3.sum(arrayOfAllEntriesForProject, d => d.duration),
// the projects are the keys to grouping the data
entry => entry.project
);
// get the max duration across all projects
const maxProjectDuration = d3.max([...projects].map(project => totalDurationPerProject.get(project)));
const transformedData = [
{person: 'Frodo Baggins', project: 'Project a', duration: 21},
{person: 'Gandalf', project: 'Project a', duration: 32},
{person: 'Samwise Gamgee', project: 'Project f', duration: 88},
{person: 'Samwise Gamgee', project: 'Project g', duration: 27},
{person: 'Samwise Gamgee', project: 'Project d', duration: 148},
{person: 'Samwise Gamgee', project: 'Project h', duration: 34},
]
// individual people and projects
const people = d3.union(transformedData.map(d => d.person));
const projects = d3.union(transformedData.map(d => d.project));
// use rollup to get the time spend on each project
const totalDurationPerProject = d3.rollup(
transformedData,
// sum up the duration for each project
arrayOfAllEntriesForProject => d3.sum(arrayOfAllEntriesForProject, d => d.duration),
// the projects are the keys to grouping the data
entry => entry.project
);
// get the max duration across all projects
const maxProjectDuration = d3.max([...projects].map(project => totalDurationPerProject.get(project)));
// create the map project -> person
const projectToPersonMap = d3.index(transformedData, d => d.project, d => d.person);
const stackedData = d3.stack()
.keys(people)
.value(([project, group], key) => {
if (!group.has(key)) {
return 0;
} else {
return group.get(key).duration;
}
})(projectToPersonMap);
// we need to add some margin to see the full axis
const margin = {top: 10, bottom: 150, left: 30, right: 10};
// static width and height - change this to whatever suits you
const width = 500;
const height = 300;
// different colors for different people
const color = d3.scaleOrdinal()
.domain(people)
.range(['#e41a1c', '#377eb8', '#4daf4a']);
const x = d3.scaleBand()
.domain(projects)
.range([0, width])
.padding([0.2])
const y = d3.scaleLinear(
// the domain from max project duration
[0, maxProjectDuration],
// over the entire height
[height, 0]
);
const svg = d3.select('#stacked-chart')
.append('svg')
// set the size of the chart
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
const chartGroup = svg.append('g')
.attr('class', 'bars')
.attr('transform', `translate(${margin.left} ${margin.top})`);
chartGroup
.selectAll('g.person')
.data(stackedData)
.join('g')
.attr('class', d => {
return `person ${encodeURI(d.key.replace(' ', '-').toLowerCase())}`;
})
.attr('fill', d => color(d.key))
.selectAll('rect')
.data(d => d)
.enter()
// optionally filter those bars which are of zero height (missing person to project combination)
.filter(d => {
return (d[1] - d[0]) > 0;
})
.append('rect')
.attr('class', d => {
return `project ${encodeURI(d.data[0].replace(' ', '-').toLowerCase())}`;
})
// the x position and width comes from the band scale
.attr('x', d => {
return x(d.data[0]);
})
.attr('width', x.bandwidth())
// add y position and height
.attr('y', d => y(d[1]))
.attr('height', d => (y(d[0]) - y(d[1])));
// add x and y axis
svg.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(${margin.left} ${height + margin.top})`)
.call(d3.axisBottom(x));
svg.append('g')
.attr('class', 'y-axis')
.attr('transform', `translate(${margin.left} ${margin.top})`)
.call(d3.axisLeft(y));
// legend at the bottom
const legendRowHeight = 15;
const legendRowMargin = 3;
const legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${margin.left} ${margin.top + height + 20})`)
const legendEntryPerPerson = legend.selectAll('g.person')
.data(people)
.join('g')
.attr('class', d => `person ${encodeURI(d.replace(' ', '-').toLowerCase())}`)
.attr('transform', (d, i) => `translate(20 ${i * (legendRowHeight + legendRowMargin)})`)
legendEntryPerPerson.append('rect')
.attr('x', 0).attr('y', 0)
.attr('width', legendRowHeight).attr('height', legendRowHeight)
.attr('fill', d => color(d));
legendEntryPerPerson.append('text')
.attr('x', (legendRowHeight + legendRowMargin)).attr('y', 0)
.attr('dy', legendRowHeight)
.text(d => d);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stacked Bar Charts</title>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div style="text-align: center;">
<h1>Stacked Bar Charts</h1>
<div id="stacked-chart"></div>
</div>
</body>
</html>