chart = {
const svg = d3.select(DOM.svg(width, height))
const g = svg.append('g').attr('transform', `translate(${margin.left}, ${margin.top})`)
const route = g.append("g").attr('class', 'routes')
.attr("fill", "none")
.attr("stroke-opacity", .1)
.attr("stroke", "#EEE")
.selectAll("path").data(routes)
.join("path")
.attr('d', sankeyLinkCustom)
.attr("stroke-width", bandHeight)
route.each(function(nodes) {
const path = this
const length = path.getTotalLength()
const points = d3.range(length).map(l => {
const point = path.getPointAtLength(l)
return { x: point.x, y: point.y }
})
const lastNode = nodes[nodes.length - 1]
const key = '/' + nodes.map(n => n.name).join('/')
cache[key] = { points }
})
// Create a container for particles first,
// to keep particles below the labels which are declared next
const particlesContainer = g.append('g')
// Labels
//
g.selectAll('.label').data(sankey.nodes) // `.slice(1)` to skip the root node
.join('g').attr('class', 'label')
.attr('transform', d => `translate(${d.x1 - bandHeight / 2}, ${d.y0 + bandHeight / 2})`)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'end')
// This is how we make labels visible on multicolor background
// we create two <text> with the same label
.call(label => label.append('text')
// the lower <text> serves as outline to make contrast
.attr('stroke', 'white')
.attr('stroke-width', 3)
.text(d => d.name))
// the upper <text> is the actual label
.call(label => label.append('text')
.attr('fill', '#444')
.text(d => d.name))
// Counters
//
const counters = g.selectAll('.counter').data(leaves)
.join('g').attr('class', 'counter')
.attr('transform', d => `translate(${width - margin.left}, ${d.node.y0})`)
.each(function(leaf, i) {
d3.select(this).selectAll('.group').data(['males', 'females'])
.join('g').attr('class', 'group')
.attr('transform', (d, i) => `translate(${-i * 60}, 0)`)
// Align coutners to the right, because running numbers are easier for the eye to compare this way
.attr('text-anchor', 'end')
// Use monospaced font to keep digits aligned as they change during the animation
.style('font-family', 'Menlo')
// Add group titles only once, on the top
.call(g => i === 0 && g.append('text')
.attr('dominant-baseline', 'hanging')
.attr('fill', '#999')
.style('font-size', 9)
.style('text-transform', 'uppercase')
.style('letter-spacing', .7) // a rule of thumb: increase letter spacing a bit, when use uppercase
.text(d => d)
)
// Absolute counter values
.call(g => g.append('text').attr('class', 'absolute')
.attr('fill', d => colorScale(d))
.attr('font-size', 20)
.attr('dominant-baseline', 'middle')
.attr('y', bandHeight / 2 - 2)
.text(0) // will be updated during the animation
)
// Percentage counter values
.call(g => g.append('text').attr('class', 'percent')
.attr('dominant-baseline', 'hanging')
.attr('fill', '#999')
.attr('font-size', 9)
.attr('y', bandHeight / 2 + 9)
.text('0%') // will be updated during the animation
)
})
// Instead of `return svg.node()` we do this trick.
// It's needed to expose `update` function outside of this cell.
// It's Observable-specific, you can see more animations technics here:
// https://observablehq.com/@d3/learn-d3-animation?collection=@d3/learn-d3
//
return Object.assign(svg.node(), {
// update will be called on each tick, so here we'll perform our animation step
update(t) {
// add particles if needed
//
addParticlesMaybe(t)
// update counters
//
counters.each(function(d) {
const finished = particles
.filter(p => p.target.name === d.node.name)
.filter(p => p.pos >= p.length)
d3.select(this).selectAll('.group').each(function(group) {
const count = finished.filter(p => p.target.group === group).length
d3.select(this).select('.absolute').text(count)
d3.select(this).select('.percent').text(d3.format('.0%')(count / totalParticles))
})
})
// move particles
//
particlesContainer.selectAll('.particle').data(particles.filter(p => p.pos < p.length), d => d.id)
.join(
enter => enter.append('rect')
.attr('class', 'particle')
.attr('opacity', 0.8)
.attr('fill', d => d.color)
.attr('width', psize)
.attr('height', psize),
update => update,
exit => exit
//.remove() // uncomment to remove finished particles
)
// At this point we have `cache` with all possible coordinates.
// We just need to figure out which exactly coordinates to use at time `t`
//
.each(function(d) {
// every particle appears at its own time, so adjust the global time `t` to local time
const localTime = t - d.createdAt
d.pos = localTime * d.speed
// extract the current and the next point coordinates from the precomputed cache
const index = Math.floor(d.pos)
const coo = cache[d.target.path].points[index]
const nextCoo = cache[d.target.path].points[index + 1]
if (coo && nextCoo) {
// `index` is integer, but `d.pos` is float, so there are ticks when a particle is
// between the two precomputed points. We use `delta` to compute position between the current
// and the next coordinates to make the animation smoother
const delta = d.pos - index // try to set it to 0 to see how jerky the animation is
const x = coo.x + (nextCoo.x - coo.x) * delta
const y = coo.y + (nextCoo.y - coo.y) * delta
// squeeze particles when they close to finish
const lastX = cache[d.target.path].points[d.length - 1].x
const squeezeFactor = Math.max(0, psize - (lastX - x)) // gets from 0 to `psize`, when finish
const h = Math.max(2, psize - squeezeFactor) // gets from `psize` to 2
const dy = (psize - h) / 2 // increases as the particle squeezes, to keep it centered
const w = psize + squeezeFactor // the width increses twice, when finish
const dx = squeezeFactor / 2 // compensates x position when the width increases
d3.select(this)
.attr('x', x - dx)
.attr('y', y + d.offset + dy)
.attr('height', h)
.attr('width', w)
}
})
}
})
}