I think this is an interesting case of trying to bind data that changes to a D3 general update pattern, with a seemingly odd issue.
I have created a reproducible example for this issue I am having with React and D3. I have spent a lot of time reading D3 docs, but haven't found a solution.
I am attempting to use D3's general update pattern to create an interactive scatter plot. One of the important features of my web apps actual scatter plot is the ability to choose amongst 3 different datasets for the data for the scatter plot. At a high level, the data flow in my app is:
In the example below, both dummy datasets have an id
column, and in my actual data this is also the case amongst all 3 datasets. However, the keys vary between datasets (this is the point). The issue is as such (and is highlighted with console.logs()) - Even though it appears (see console.log) as though the correct array of objects is being passed into the GraphApp component, it seems that the D3 general update pattern is not identifying the keys in the new graphData
prop.
The example below will highlight this issue better than my words above - I tried to keep the example as short as possible, although a certain minimum of code (2 components, a button group, a scatter plot) was needed:
// AppGraph is responsible for drawing the scatterplot (of text names in this example)
class AppGraph extends React.Component {
constructor(props) {
super(props);
}
drawPoints() {
const { graphData, graphType } = this.props;
console.log('Data INSIDE of AppGraph component');
console.log('graphData: ', graphData);
console.log('graphType: ', graphType);
const xShift = function(d) {
if (graphType === "A") {
return d.pts
} else {
return d.reb
}
}
const yShift = function(d) {
if (graphType === "A") {
return d.ast
} else {
return d.blk
}
}
// This is my general update pattern code
// Likely something is wrong here, but i have been unable to pin the error
const pointsLayer = d3.select('#my-svg').select('g.points')
let groups = pointsLayer
.selectAll(".myGroups")
.data(graphData, d => d.id);
const groupsExit = groups.exit().remove();
const groupsEnter = groups.enter()
.append("g")
.attr("class", "myGroups");
groupsEnter.append("circle")
.attr("cx", d => xShift(d))
.attr("cy", d => yShift(d));
groups
.selectAll("circle")
.transition()
.duration(1000)
.attr("cx", d => xShift(d))
.attr("cy", d => yShift(d));
groupsEnter.append("text")
.attr("x", d => xShift(d))
.attr("y", d => yShift(d))
.text(d => d.id);
groups
.selectAll("text")
.transition()
.duration(1000)
.attr("x", d => xShift(d))
.attr("y", d => yShift(d))
.text(d => d.id);
groups = groupsEnter.merge(groups);
groupsExit.remove()
}
componentDidMount() {
d3.select('#my-svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', "0 0 " + (800) + " " + 600)
.attr('preserveAspectRatio', "xMaxYMax")
this.drawPoints();
}
componentDidUpdate() {
this.drawPoints()
}
render() {
return (
<svg id="my-svg">
<g className="points" />
</svg>
)
}
}
// App is the Parent component that selects the correct dataset, and passes it into the Graph component
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
graphType: "A"
}
}
handleButtonChange = (event) => {
this.setState({
graphType: event.target.value
});
};
render() {
const { graphType } = this.state;
// The two datasets to choose from
const datasetA = [
{ id: 'tom', pts: 134, ast: 12 },
{ id: 'joe', pts: 224, ast: 114 },
{ id: 'bim', pts: 114, ast: 215 },
{ id: 'tim', pts: 243, ast: 16 },
{ id: 'nik', pts: 210, ast: 17 },
{ id: 'jib', pts: 312, ast: 287 }
];
const datasetB = [
{ id: 'tom', reb: 115, blk: 32 },
{ id: 'joe', reb: 122, blk: 224 },
{ id: 'bim', reb: 211, blk: 55 },
{ id: 'tim', reb: 241, blk: 366 }
];
// Radio button group determines the dataset passed to scatterplot
const graphData = graphType === "A" ? datasetA : datasetB;
// Render radio buttons and scatter plot
return (
<div>
<form>
<div>
<label>
<input
type = {"radio"}
value = {"A"}
checked = {graphType === "A"}
onChange = {this.handleButtonChange}
/>
<span> {"A"} </span>
</label>
</div>
<div>
<label>
<input
type = {"radio"}
value = {"B"}
checked = {graphType === "B"}
onChange = {this.handleButtonChange}
/>
<span> {"B"} </span>
</label>
</div>
</form>
<AppGraph
graphType={graphType}
graphData={graphData}
/>
</div>
);
}
}
ReactDOM.render(
<App /> ,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom.development.js"></script>
<div id='root'>
Come On Work!
</div>
Please momentarily full screen the code demo so you can see the scatterplot in action without the console.logs() taking up the full window.
As you can see, the scatterplot breaks when toggling from graphType
A to B. Even though the new graphData
array is correctly passed into AppGraph
, the x and y values are not observed on graphData's
new keys (reb, and blk), and the scatterplot fails to update correctly.
I am very curious what I am doing wrong with regards to data flow / binding data in the general update pattern.
Edit: As a note, combining the 3 datasets into a single dataset would be very burdensome for a variety of reasons, and the current approach (updating graphData
based on the radio buttons selected) is highly preferred.
Edit 2: I am beginning to suspect that the following line of code is not correct:
let groups = pointsLayer
.selectAll(".myGroups")
.data(graphData, d => d.id);
but I am not positive and have been unable so far to fix it.
This is due to the fact that selection.selectAll()
does not propagate data to the selected elements:
The selected elements do not inherit data from this selection
Although you correctly bind the updated graphData
to the groups, it is never passed on to the texts and circles, which still have the old data bound to them via the initial append. A way around this is to use selection.select()
instead:
Unlike selection.selectAll, selection.select […] propagates data (if any) to selected children.
Putting this into your code makes it work like intended:
// AppGraph is responsible for drawing the scatterplot (of text names in this example)
class AppGraph extends React.Component {
constructor(props) {
super(props);
}
drawPoints() {
const { graphData, graphType } = this.props;
console.log('Data INSIDE of AppGraph component');
console.log('graphData: ', graphData);
console.log('graphType: ', graphType);
const xShift = d => graphType === "A" ? d.pts : d.reb;
const yShift = d => graphType === "A" ? d.ast : d.blk;
// This is my general update pattern code
// Likely something is wrong here, but i have been unable to pin the error
const pointsLayer = d3.select('#my-svg').select('g.points')
let groups = pointsLayer
.selectAll(".myGroups")
.data(graphData, d => d.id);
const groupsExit = groups.exit().remove();
const groupsEnter = groups.enter()
.append("g")
.attr("class", "myGroups");
groupsEnter.append("circle")
.attr("cx", xShift)
.attr("cy", yShift);
groups
.select("circle")
.transition()
.duration(1000)
.attr("cx", xShift)
.attr("cy", yShift);
groupsEnter.append("text")
.attr("x", xShift)
.attr("y", yShift)
.text(d => d.id);
groups
.select("text")
.transition()
.duration(1000)
.attr("x", xShift)
.attr("y", yShift)
.text(d => d.id);
}
componentDidMount() {
d3.select('#my-svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', "0 0 " + (800) + " " + 600)
.attr('preserveAspectRatio', "xMaxYMax")
this.drawPoints();
}
componentDidUpdate() {
this.drawPoints()
}
render() {
return (
<svg id="my-svg">
<g className="points" />
</svg>
)
}
}
// App is the Parent component that selects the correct dataset, and passes it into the Graph component
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
graphType: "A"
}
}
handleButtonChange = (event) => {
this.setState({
graphType: event.target.value
});
};
render() {
const { graphType } = this.state;
// The two datasets to choose from
const datasetA = [
{ id: 'tom', pts: 134, ast: 12 },
{ id: 'joe', pts: 224, ast: 114 },
{ id: 'bim', pts: 114, ast: 215 },
{ id: 'tim', pts: 243, ast: 16 },
{ id: 'nik', pts: 210, ast: 17 },
{ id: 'jib', pts: 312, ast: 287 }
];
const datasetB = [
{ id: 'tom', reb: 115, blk: 32 },
{ id: 'joe', reb: 122, blk: 224 },
{ id: 'bim', reb: 211, blk: 55 },
{ id: 'tim', reb: 241, blk: 366 }
];
// Radio button group determines the dataset passed to scatterplot
const graphData = graphType === "A" ? datasetA : datasetB;
// Render radio buttons and scatter plot
return (
<div>
<form>
<div>
<label>
<input
type = {"radio"}
value = {"A"}
checked = {graphType === "A"}
onChange = {this.handleButtonChange}
/>
<span> {"A"} </span>
</label>
</div>
<div>
<label>
<input
type = {"radio"}
value = {"B"}
checked = {graphType === "B"}
onChange = {this.handleButtonChange}
/>
<span> {"B"} </span>
</label>
</div>
</form>
<AppGraph
graphType={graphType}
graphData={graphData}
/>
</div>
);
}
}
ReactDOM.render(
<App /> ,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom.development.js"></script>
<div id='root'>
Come On Work!
</div>
Aside: Although all the links in this answer refer to the latest version 5 the same holds true for version 4 which you are using.