Incorrect element transitions during data reordering in d3.selection.join
0 reputation · 29 Nov 2021, 15:37 UTC
0 reputation · 29 Nov 2021, 15:37 UTC
When utilizing the d3.selection.join() method in D3.js v5+, the library manages the lifecycle of DOM elements by mapping a data array to a selection. By default, this mapping is performed based on the index of the element within the array.
A conflict arises when the underlying dataset is reordered or filtered. If the .data() method is called without a specific key function, D3 binds the new data to existing elements based on their position rather than a unique identifier. This behavior can cause visual artifacts where elements appear to morph into unrelated data points during transitions instead of moving to their new coordinates.
What is the precise mechanism for implementing a key function within the .data() call to ensure join() maintains object constancy? How does the internal __data__ property update when a key function is provided versus when it is omitted?
34775 reputation · 30 Nov 2021, 03:25 UTC
To make d3.selection.join() keep the same DOM nodes bound to the same data items when the array is reordered or filtered, supply a key function as the second argument to .data(). The key function must return a value that is unique and stable for each datum (e.g., an id field).
.data(data) is called without a key, D3 uses the array index as the implicit key. __data__ on each DOM node is set to the datum that currently occupies that index.key(d) is provided, D3 builds a map from key values to existing nodes. It then matches incoming data to nodes by comparing key(d) values. The __data__ property of a node is updated only when the datum with the matching key is assigned to that node; otherwise the node enters the exit selection..join() creates the enter, update, and exit selections, so the join works on correctly paired nodes.const data = [{id: 'a', value: 1}, {id: 'b', value: 2}, ...];const svg = d3.select('svg');
const circles = svg.selectAll('circle')
.data(data, d => d.id) // ← key function
.join(
enter => enter.append('circle').attr('r', 5),
update => update, // update selection (no change needed)
exit => exit.remove()
);
update selection (or to the join result via a call):
circles.call(g => g.transition().duration(500)
.attr('cx', d => d.value * 10)
.attr('cy', 50));
The most common remaining cause is that the transition is being started on the enter selection instead of update. Verify by logging which nodes receive the start event; only nodes that already existed should animate.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 29 Nov 2021, 16:04 UTC
One detail worth adding to the answer above: the key function is invoked twice per join — once for each incoming datum and once for each existing node, using the node's current __data__ value. That's how D3 builds the old-key-to-node map it matches against. A practical consequence: if your key function dereferences a property that can be missing (e.g., d.id when some legacy datum lacks id), the old-side lookup silently produces undefined keys and those nodes fall into exit even though the data still exists.
Also note that keys are coerced to strings internally (the map is keyed on key(d) + '' in the v5/v6/v7 source), so numeric ids 1 and string ids '1' collide. Keep one consistent type.
Quick verification: after a reorder, log update.size(), enter.size(), and exit.size(). With a correct key, a pure reorder yields enter = 0, exit = 0; with index binding you'll see spurious enter/exit churn. Assumes D3 v5+; behavior is unchanged through v7.