Public
Edited
Oct 12, 2023
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart = {
const svg = d3.select(DOM.svg(width, height));

// Apart from aesthetic function links serve as trajectory for moving particles.
// We'll compute particle positions in the next step
//
const link = svg
.append("g")
.attr("class", "links")
.attr("fill", "none")
.attr("stroke-opacity", 0.04)
.attr("stroke", "#aaa")
.selectAll("path")
.data(links)
.join("path")
// use custom sankey function here because we don't care of the node heights and link widths
.attr("d", sankeyLinkCustom)
.attr("stroke-width", yScale.bandwidth());

// Compute particle positions along the lines.
// This technic relies on path.getPointAtLength function that returns coordinates of a point on the path
// Another example of this technic:
// https://observablehq.com/@oluckyman/point-on-a-path-detection
//
link.each(function (d) {
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 key = `${d.source}_${d.target}`;
cache[key] = { points };
});

// Instead of just `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) {
if (particles.length < totalParticles) {
addParticlesMaybe(t);
}

svg
.selectAll(".particle")
.data(
particles.filter((p) => p.pos < p.length),
(d) => d.id
)
.join(
(enter) =>
enter
.append("rect")
.attr("class", "particle")
.attr("fill", (d) => d.color)
.attr("width", psize)
.attr("height", psize),
(update) => update,
(exit) => exit.remove()
)
// 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 current and next coordinates of the point from precomputed cache
const index = Math.floor(d.pos);
const coo = cache[d.route].points[index];
const nextCoo = cache[d.route].points[index + 1];
if (coo && nextCoo) {
// `index` is integer, but `pos` is not, so there are ticks when the 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;
d3.select(this)
.attr("x", x)
.attr("y", y + d.offset);
}
});
}
});
}
Insert cell
Insert cell
Insert cell
Insert cell
source = routes[2].target // 0..4, where the source node is
Insert cell
Insert cell
// extract routes from students
routesAbsolute = Object.keys(students)
.filter(key => key.startsWith('bit'))
.map(target => ({ target, value: students[target] }))
Insert cell
routes = {
// normalize values
const total = d3.sum(routesAbsolute, d => d.value)
return routesAbsolute.map(r => ({ ...r, value: r.value / total }))
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// Randomly add 0 to `density` particles per tick `t`
addParticlesMaybe = (t) => {
const particlesToAdd = Math.round(Math.random() * density)
for (let i = 0; i < particlesToAdd; i++) {
const target = routeScale(Math.random())
const route = `${source}_${target}`
const length = cache[route].points.length
const particle = {
// `id` is needed to distinguish the particles when some of them finish and disappear
id: `${t}_${i}`,
speed: speedScale(Math.random()),
// used to position a particle vertically on the band
offset: offsetScale(Math.random()),
// now is used for aesthetics only, can be used to encode different types (e.g. male vs. female)
// color: d3.interpolatePiYG(Math.random() * 0.3),
color: colorScale(Math.random()),
// current position on the route (will be updated in `chart.update`)
pos: 0,
// total length of the route, used to determine that the particle has arrived
length,
// when the particle is appeared
createdAt: t,
// route assigned to that particle
route,
}
particles.push(particle)
}
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// takes a random number [0..1] and returns a color, based on male/female distribution
colorScale = {
const total = students.males + students.females
const colorThresholds = [students.females / total]
return d3.scaleThreshold()
.domain(colorThresholds)
.range(['plum', 'powderblue'])
}
Insert cell
Insert cell
psize = 7 // particle size
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Select a data source…
Type Chart, then Shift-Enter. Ctrl-space for more options.

Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more