Skip to content

Instantly share code, notes, and snippets.

@cmgiven
Last active October 27, 2019 19:52
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save cmgiven/32d4c53f19aea6e528faf10bfe4f3da9 to your computer and use it in GitHub Desktop.
Save cmgiven/32d4c53f19aea6e528faf10bfe4f3da9 to your computer and use it in GitHub Desktop.
Interactive General Update Pattern
license: mit
height: 640

An interactive exploration of the general update pattern (with the v4 API). Use the arrow keys to navigate between lines of code.

The goal is to demonstrate repeated loops through a simplified update function, advancing one line at a time. Details like positioning are omitted so as to focus only on 1) what is selected and 2) what exists in the DOM.

From an idea by Alex Engler. Suggestions or feedback welcome.

<!DOCTYPE html>
<meta charset="utf-8">
<style>
body > div { float: left; width: 480px; }
#code { font-size: 16px; }
#description {
box-sizing: border-box;
width: 100%;
height: 235px;
background: #cef;
padding: 16px;
position: relative;
}
.step-group {
background: #eee;
border-top: 1px solid #fff;
padding: 6px 0;
}
.step-group.active { background: #cef; }
pre {
margin: 0;
padding: 2px 8px;
}
pre.step.active {
background: #0ad;
color: #fff;
}
text {
font-family: monospace;
font-size: 16px;
}
.node text {
text-transform: uppercase;
font-size: 30px;
font-weight: 700;
text-anchor: middle;
}
circle.selected {
fill: none;
stroke: #0ad;
stroke-width: 3;
}
.step-description { display: none; }
.step-description.active { display: block; }
h2 {
font-family: monospace;
float: left;
margin: 0;
}
p {
float: left;
margin: .25em 0 0 1em;
max-width: 42em;
line-height: 1.35;
font-family: sans-serif;
}
button {
position: absolute;
bottom: 16px;
right: 16px;
margin: 0 auto;
padding: 1em 1em;
background: #0ad;
color: #fff;
border: 0;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
</style>
<body>
<div id="code">
<div class="step-group">
<pre>function update(data) {</pre>
</div>
<div class="step-group">
<pre id="step-1" class="step update-selected exit-selected">
var update = svg.selectAll('circle')
</pre>
<pre id="step-2" class="step data-bound update-selected">
.data(data, function (d) { return d })
</pre>
</div>
<div class="step-group">
<pre id="step-3" class="step data-bound enter-selected">
var enter = update.enter()
</pre>
<pre id="step-4" class="step data-bound enter-selected enter-circle">
.append('circle')
</pre>
</div>
<div class="step-group">
<pre id="step-5" class="step data-bound enter-circle exit-selected">
var exit = update.exit()
</pre>
</div>
<div class="step-group">
<pre id="step-6" class="step data-bound update-selected update-fill enter-circle">
update.style('fill', 'black')
</pre>
</div>
<div class="step-group">
<pre id="step-7" class="step data-bound update-fill enter-selected enter-circle enter-fill">
enter.style('fill', 'green')
</pre>
</div>
<div class="step-group">
<pre id="step-8" class="step data-bound update-fill enter-circle enter-fill exit-selected exit-fill">
exit.style('fill', 'red')
</pre>
<pre id="step-9" class="step data-bound update-fill enter-circle enter-fill exit-selected exit-fill exit-remove">
.remove()
</pre>
</div>
<div class="step-group">
<pre id="step-10" class="step data-bound update-selected update-fill enter-selected enter-circle enter-fill exit-remove">
update.merge(enter)
</pre>
<pre id="step-11" class="step data-bound update-selected update-fill enter-selected enter-circle enter-fill exit-remove pulse">
.call(pulse)
</pre>
</div>
<div class="step-group">
<pre>}</pre>
</div>
</div>
<div id="results"></div>
<div id="description">
<div id="step-description-1" class="step-description">
<h2>.selectAll()</h2>
<p>First, we select all the circles that exist. We do this even when we know for certain there are none because we need an empty selection inside of which to create circles.</p>
<button onclick="step(2)">Advance to the Next Line</button>
</div>
<div id="step-description-2" class="step-description">
<h2>.data()</h2>
<p>Now we tell D3 what circles we want to exist by giving it our data, an array of letters between A and H that were randomly selected. Two things to note here: 1. The second argument is a <em>key function</em>, which tells D3 how to recognize that two items are the same between update() calls; in this case, we're using a simple equality check, but we might typically need to provide D3 with a unique identifier, as other data variables might change. 2. The selection is now different; only the circles that already existed and will continue to exist are selected. These circles are what are assigned to the "update" variable (our <em>update selection</em>).</p>
<button onclick="step(3)">Advance to the Next Line</button>
</div>
<div id="step-description-3" class="step-description">
<h2>.enter()</h2>
<p>Although we're showing letters in the top left to illustrate that D3 knows about them, circles that correspond to these letters don't yet exist. By calling .enter() on the update selection, we select these non-existent circles, so that&hellip;</p>
<button onclick="step(4)">Advance to the Next Line</button>
</div>
<div id="step-description-4" class="step-description">
<h2>.append()</h2>
<p>&hellip;we can create each one. This is our <em>enter selection</em>, and it contains any circle that didn't exist when we called .data().</p>
<button onclick="step(5)">Advance to the Next Line</button>
</div>
<div id="step-description-5" class="step-description">
<h2>.exit()</h2>
<p>Just as there is an enter selection, we can create an <em>exit selection</em> by calling .exit() on the update selection. These are all the circles that do exist but are no longer found in the data, so we'll want to get rid of them. By using these selections, we keep what is shown in the browser consistent with the data, and have a handy means of transitioning elements in and out.</p>
<button onclick="step(6)">Advance to the Next Line</button>
</div>
<div id="step-description-6" class="step-description">
<h2>.style()</h2>
<p>Each of these selections can be manipulated, either separately or in combination. Let's help identify those circles that already existed by coloring them black.</p>
<button onclick="step(7)">Advance to the Next Line</button>
</div>
<div id="step-description-7" class="step-description">
<h2>.style()</h2>
<p>We'll make the newly entered circles green.</p>
<button onclick="step(8)">Advance to the Next Line</button>
</div>
<div id="step-description-8" class="step-description">
<h2>.style()</h2>
<p>And the circles that are about to exit will be turned red.</p>
<button onclick="step(9)">Advance to the Next Line</button>
</div>
<div id="step-description-9" class="step-description">
<h2>.remove()</h2>
<p>Those red circles can't be permitted to remain, so we call .remove() to delete them.</p>
<button onclick="step(10)">Advance to the Next Line</button>
</div>
<div id="step-description-10" class="step-description">
<h2>.merge()</h2>
<p>Note that we don't have to operate on these selections independently. By using .merge(), we can combine two selections. This lets us perform an operation on all of the remaining circles, both those that already existed and those that were just added.</p>
<button onclick="step(11)">Advance to the Next Line</button>
</div>
<div id="step-description-11" class="step-description">
<h2>.call(pulse)</h2>
<p>For example, we'll call a custom function that will make both the enter and update selections gently pulse. When we're ready, we can start the whole process over again with new data.</p>
<button onclick="loop()">Run Update() Again</button>
</div>
</div>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>
var width = 480
var height = 405
var radius = 26
var alphabet = 'abcdefgh'.split('')
var activeStep
var stepCount = 11
var svg = d3.select('#results').append('svg')
.attr('width', width)
.attr('height', height)
var dataLabel = svg.append('text')
.attr('x', 12)
.attr('y', 23)
var nodes = []
var enterNodes = []
var updateNodes = []
var update = svg.selectAll('.node').data(nodes)
var enter = update.enter()
var exit = update.exit()
var simulation = d3.forceSimulation()
.on('tick', ticked)
.force('centerX', d3.forceX(width / 2).strength(0.05))
.force('centerY', d3.forceY(height / 2).strength(0.05))
.force('collide', d3.forceCollide(function (d) { return d.active ? radius + 6 : 0 }))
.nodes(nodes)
loop()
function loop() {
step(10)
enter.each(function (d) { d.oldFill = 'green' })
update.each(function (d) { d.oldFill = 'black' })
exit.remove()
nodes = updateNodes
updateNodes = []
enterNodes = []
var letters = d3.shuffle(alphabet)
.slice(0, Math.floor(Math.random() * alphabet.length) + 1)
.sort()
letters.forEach(function (letter) {
var node = nodes.find(function (d) { return d.letter === letter })
if (!node) {
node = { letter: letter }
enterNodes.push(node)
}
updateNodes.push(node)
})
nodes = nodes.concat(enterNodes)
update = svg.selectAll('.node').data(updateNodes, function (d) { return d.letter })
enter = update.enter().append('g').attr('class', 'node')
.each(function (d) { d.oldFill = '#aaa' })
exit = update.exit()
enter.append('circle')
.attr('class', 'bg')
.attr('r', 0)
enter.append('text')
.attr('dy', '.25em')
.attr('opacity', 0)
.text(function (d) { return d.letter })
enter.append('circle')
.attr('class', 'selected')
.attr('r', radius + 4)
.attr('stroke-dasharray', '6,5')
.attr('opacity', 0)
dataLabel.text('data = ["' + letters.join('", "') + '"]')
simulation
.nodes(nodes)
.alpha(1)
.restart()
step(1)
}
function step(_) {
var el, number
if (typeof _ === 'number') {
el = d3.select('#step-' + _)
number = _
} else {
el = _
number = parseInt(_.attr('id').split('-')[1], 10)
}
activeStep = number
d3.selectAll('.step-group').classed('active', false)
d3.selectAll('.step').classed('active', false)
d3.selectAll('.step-description').classed('active', false)
el.classed('active', true)
d3.select(el.node().parentNode).classed('active', true)
d3.select('#step-description-' + number).classed('active', true)
var state = d3.set(el.attr('class').split(' '))
var i = 0
enter.each(function (d) {
d.fx = state.has('enter-circle') ? null : (++i) * radius + 4
d.fy = state.has('enter-circle') ? null : radius * 2 + 8
d.active = state.has('enter-circle')
})
exit.each(function (d) { d.active = !state.has('exit-remove') })
if (!state.has('pulse')) {
enter.merge(update).selectAll('.bg').interrupt()
}
enter.selectAll('.bg')
.transition()
.attr('r', function () { return state.has('enter-circle') ? radius : 0 })
.style('fill', function (d) { return state.has('enter-fill') ? 'green' : d.oldFill })
update.selectAll('.bg')
.transition()
.attr('r', radius)
.style('fill', function (d) { return state.has('update-fill') ? '#000' : d.oldFill })
exit.selectAll('.bg')
.transition()
.attr('r', function () { return !state.has('exit-remove') ? radius : 0 })
.style('fill', function (d) { return state.has('exit-fill') ? 'red' : d.oldFill })
enter.selectAll('text')
.attr('opacity', function () { return state.has('data-bound') ? 1 : 0 })
.style('fill', function () { return state.has('enter-circle') ? '#fff' : '#000' })
exit.selectAll('text')
.attr('opacity', function () { return state.has('exit-remove') ? 0 : 1 })
enter.selectAll('.selected')
.attr('opacity', function () { return state.has('enter-selected') ? 1 : 0 })
update.selectAll('.selected')
.attr('opacity', function () { return state.has('update-selected') ? 1 : 0 })
exit.selectAll('.selected')
.attr('opacity', function () { return state.has('exit-selected') && !state.has('exit-remove') ? 1 : 0 })
if (state.has('pulse')) {
enter.merge(update)
.selectAll('.bg')
.transition()
.on('start', function repeat() {
d3.active(this).attr('r', radius - 2)
.transition().duration(600).attr('r', radius + 2)
.transition().on('start', repeat)
})
}
simulation.nodes(nodes)
if (simulation.alpha() < 0.3) { simulation.alpha(0.3).restart() }
}
function ticked() {
svg.selectAll('.node')
.attr('transform', function (d) { return 'translate(' + d.x + ',' + d.y + ')' })
}
window.focus()
d3.select(window).on('keydown', function () {
var number = activeStep
switch (d3.event.keyCode) {
case 37: number -= 1; break // left
case 38: number -= 1; break // up
case 39: number += 1; break // right
case 40: number += 1; break // down
}
if (number !== activeStep) {
d3.event.preventDefault()
if (number > stepCount) { loop() } else if (number > 0) { step(number) }
}
})
d3.selectAll('.step').on('mouseover', function () { step(d3.select(this)) })
</script>
</body>
@Fil
Copy link

Fil commented Sep 7, 2016

This is fantastic!! Bravo!

One small issue is that update is two very different things (the function, and the selection). I would suggest to rename var update to letters or selection.

@Fil
Copy link

Fil commented Sep 7, 2016

I've made a French translation here http://bl.ocks.org/Fil/e681ce83b87a26e6e78edb82b42e9444 (marked UNLISTED for the moment).

Added credits/intro on the first line of code. Tell me what you think?

@cmgiven
Copy link
Author

cmgiven commented Sep 7, 2016

Thank you, that's amazing! Please share with the world!

On update, yes, I'm aware. I'm reticent to rename the variable, because even though it would often be named "letters" or "circles", this is one of the things that I've noticed can confuse about the general update pattern, particularly now that the selection.enter() mutating magic has been removed in v4. So it was important to be explicit in the code about what selection it represented, and to have that name be parallel to enter and exit. Maybe the function could be renamed, although update is a pretty common convention there.

@Fil
Copy link

Fil commented Sep 7, 2016

I have removed my changes from the translation.

@zhenron
Copy link

zhenron commented Jun 10, 2017

Hi Fil,

Thank you for the awesome interactive tutorial. I've been trying to understand the general update pattern for a long time but with your help it took me just 10 minutes.

cheers,
zhenron

@nikitaeverywhere
Copy link

Thank you man for this tutorial! You saved me from a long session of debugging caused by misunderstanding the D3 selections concept.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment