Data You Can Punch
featured
d3
data-viz
Have you been hurt by bad data? Do you want to hurt it back? With a little D3.js and Matter.js, now you can!
Published
February 12, 2025
Have you ever opened a dataset to find out that every second value was missing? Were you forced to manually geo-code the last 2,000 addresses when you ran out of Google Maps API requests? Was your original thesis scrapped because every result turned out insignificant?
This is a plot for anyone who’s ever been hurt by bad data. Grab the “fist” below and vent your frustration.
d3 = require("d3");
Matter = require("matter-js");
Engine = Matter.Engine
Bodies = Matter.Bodies
Composite = Matter.Composite
// Constants -------------------------------------------------------------------
// Mobile detection
is_mobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
// Dimensions of the SVG viewbox and margins, defines our world-space
width = is_mobile ? (screen.width * 0.9) : 800;
height = is_mobile ? (screen.width * 0.9) : 400;
stroke_width = 1;
margin_left = 10;
margin_right = is_mobile ? 80 : 100; // Leave room for the "fist"
margin_top = 10;
margin_bottom = 10;
// Collision filters for Matter.js, note that these need to be powers of 2
collision_fist = 0x0001;
collision_bar = 0x0002;
collision_mouse = 0x0004;
// Duration of D3 transitions (milliseconds)
duration_bar_fade_in = 1000;
duration_bar_fade_out = 1000;
// See here for information on using Matter.js collision filters. In this plot,
// we use them to only allow mouse interaction with the "fist" and not the bars
// of the bar plot.
//
// https://stackoverflow.com/questions/64772783/how-can-i-change-the-collisionfilter-of-an-object-so-it-can-no-longer-interact-w/73262781#73262781
// TODO: Make this a little more robust!
//
// Generate 20 years of random data, ending last year
num_years = is_mobile ? 7 : 20;
scale = is_mobile ? 5 : 10;
offsets = d3.shuffle(d3.range(-10, 10)).slice(0, num_years);
this_year = new Date().getFullYear();
data = d3
.range(this_year - num_years, this_year)
.map((year, i) => ({
label: year,
// Fix the maximum value so that the y-axis always looks consistent
value: (i == (num_years - 1)) ? (num_years - 1) * scale : Math.max((i * scale) + offsets[i], scale)
}))
// Rotating plot titles (cycle on plot re-set)
plot_title_index = [0];
plot_titles = [
"P-value of 0.055 (never published)",
"Didn’t survive FDR",
"Lasso selected none of my favourites",
"Adding another hyperprior didn’t help",
"P-value of 0.00001",
"32 hour bootstrap"
];
// Helpers ---------------------------------------------------------------------
// Transform Matter body vertices into SVG <polygon> points
vertices_to_points = function(vertices) {
return vertices.map(vertex => `${vertex.x},${vertex.y}`).join(" ")
}
// SVG -------------------------------------------------------------------------
// Move the z-index of all text backwards, it looks nicer when the physics
// objects appear above the rest of the article text.
d3.select("#title-block-header")
.style("position", "relative")
.style("z-index", -1);
plot_title = d3.select("#plot-title")
.append("h3")
.text("Simulated Data")
.style("opacity", 1)
.style("position", "relative")
.style("z-index", -1)
// Prevent the cursor from highlighting text, this happens a lot while
// dragging the "fist" around, causing small glitches.
.style("user-select", "none");
// Set the SVG viewbox
svg = d3.select("#matter-container")
// TODO: I've only gotten Matter.js to work with a fixed width/height
// container. Look into whether a reactive size is possible!
// The mouse has an offset and scale, there's probably a way to allow
// for resizing: https://brm.io/matter-js/docs/classes/Mouse.html
//
// Maybe helpful:
// https://stackoverflow.com/questions/64302906/scaling-matter-js-canvas-breaks-mouse-drag
// https://github.com/liabru/matter-js/issues/955
// https://www.youtube.com/watch?v=kdSDTaYY700
.style("width", width + "px")
.style("height", height + "px")
.append("svg")
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;")
// Allows the physics objects to fall outside of the viewbox
.attr("overflow", "visible");
// Initialize a container for the plot bars and "fist". Note that we initialize
// the fist container and prompt with an explicitly opacity of 1, otherwise,
// when we later use `.transition()` to fade these elements out, their opacity
// attribute is instantly added, causing the element to "blink".
bar_container = svg.append("g").classed("bar-container", true)
fist_container = svg.append("g").classed("fist-container", true).attr("opacity", 1)
fist_prompt_container = svg.append("g").attr("id", "fist-prompt").attr("opacity", 1)
// Scales
x_axis = d3.scaleBand()
.domain(data.map(d => d.label))
.range([margin_left, width - margin_right])
.padding(0.2);
y_axis = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value)])
.range([height - margin_top, margin_bottom]);
// Add the x and y axes
svg.append("g")
.classed("x-axis", true)
// Account for the stroke width of both the bars and the axis itself
.attr("transform", `translate(0,${height - margin_bottom + (stroke_width)})`)
.call(d3.axisBottom(x_axis).tickSizeOuter(0))
// Extend the right-side of the axis line to account for the right margin.
// This gives a spot for the `fist` to land. We have to manually alter the `d`
// attribute of the <path>.
.call(g => {
const old_path = g.select(".domain").attr("d");
const new_path = old_path.replace(/H(\d+)/, (match, p1) => `H${+p1 + margin_right}`);
g.select(".domain").attr("d", new_path);
})
.call(g => g.select(".domain").attr("stroke-width", stroke_width))
.call(g => g.selectAll("line").attr("stroke-width", stroke_width))
.call(g => g.append("text")
.attr("x", width - margin_right)
// Grab the y-position from the existing tick labels, so this is aligned
.attr("y", g.select(".tick text").attr("y"))
.attr("dy", g.select(".tick text").attr("dy"))
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text("Year →")
)
// Set the axis font to the website default and prevent user selection of text
.call(g => g.selectAll("text")
.style("font-family", "var(--bs-body-font-family)")
.style("user-select", "none")
)
.lower();
svg.append("g")
.classed("y-axis", true)
// `+ 1` to connect the axis tick at `0` with the x-axis line
.attr("transform", `translate(${margin_left + 1}, ${stroke_width})`)
.call(d3.axisLeft(y_axis))
.call(g => g.select(".domain").remove())
.call(g => g.selectAll("line").attr("stroke-width", stroke_width))
.call(g => g.append("text")
.attr("x", -margin_left)
.attr("y", d3.min(y_axis.range()) - (is_mobile ? 15 : 10))
.attr("fill", "currentColor")
.attr("text-anchor", "middle")
.text("↑ Value")
)
// Set the axis font to the website default and prevent user selection of text
.call(g => g.selectAll("text")
.style("font-family", "var(--bs-body-font-family)")
.style("user-select", "none")
)
.lower();
bodies = engine.world.bodies;
// Create an invisible ground for the bars to sit on. The width shouldn't matter
// that much but I imagine thicker is a little better for collision detection.
// The y-position is important to get right so that the bars sit right ontop of
// the lower margin (`margin_bottom`).
ground_width = 10;
ground = Bodies.rectangle(
width / 2, height - (margin_bottom / 2), width, margin_bottom,
{
isStatic: true,
label: "ground"
}
);
Composite.add(engine.world, ground);
mouse = Matter.Mouse.create(document.querySelector("#matter-container"));
// mouse.pixelRatio = window.devicePixelRatio; // TODO: Figure this out
mouse_constraint = Matter.MouseConstraint.create(engine, {
mouse: mouse,
collisionFilter: {
category: collision_mouse,
mask: collision_fist
},
constraint: { stiffness: 0.5 } // Raising stiffness makes drag less "springy"
})
Composite.add(engine.world, mouse_constraint);
create_bars = function(first = false) {
// console.log(bodies[1].mass)
// console.log(bodies[1])
// Create bodies corresponding to each bar in bar chart
bar_container
.selectAll("bar")
.data(data)
// Initialize a <polygon> for each bar. We'll use the vertices
// provided by Matter to draw these later.
.join("polygon")
.classed("plot-data-blue", true)
.attr("stroke-width", stroke_width)
.attr("data-label", d => d.label)
// Set the opacity to 0, so we can fade the bars in
.attr("opacity", first ? 1 : 0)
// Initialize a Matter physics body for each bar. Note `this` means something
// different if you using a `=>` function here - be aware!
.each(function(d, i) {
const x = x_axis(d.label);
const bar_width = x_axis.bandwidth();
const bar_height = height - margin_bottom - y_axis(d.value);
// Matter expects centered x, y coordinates whereas SVG and D3 expects
// coordinates relative to the top-left.
const body = Bodies.rectangle(
// Center relative to the full band-width, to put each bar in the middle
x + (bar_width / 2),
height - margin_bottom - (bar_height / 2),
bar_width,
bar_height,
{
collisionFilter: {
category: collision_bar,
// Collide with other bars or the "fist", note that this is a bit-mask
mask: collision_fist | collision_bar
},
label: "bar"
}
);
Composite.add(engine.world, body);
// Synchronize the D3 and Matter IDs and positions
d3.select(this)
.attr("id", `body-${body.id}`)
.attr("data-body-id", body.id)
.attr("points", vertices_to_points(body.vertices))
.raise()
});
// Fade in all of the bars on re-set
if (!first) {
svg
.select(".bar-container")
.selectAll("polygon")
.transition()
.duration(duration_bar_fade_in)
.attr("opacity", 1)
}
}
// Remove the bar chart physics bodies and fade them to the screen
remove_bars = function() {
bar_container
.selectAll("polygon")
.transition()
.duration(duration_bar_fade_out)
.attr("opacity", 0)
// Remove the bar and it's physics body
.on("end", function() {
const body_id = d3.select(this).attr("data-body-id");
Composite.remove(engine.world, Composite.get(engine.world, body_id, "body"));
d3.select(this).remove();
})
}
// Create the "fist" physics body and render it to the screen
create_fist = function(first = false) {
// Slightly smaller diameter than the margin
const fist_radius = (margin_right / 2) * 0.8;
// If `first` the ball is sitting on the ground, otherwise we drop it from
// the sky.
const fist_y = first ? height - (margin_bottom / 2) - (fist_radius / 2) : 0;
// Create a circular body for the "fist"
const fist = Bodies.polygon(
width - (margin_right / 2), // Placed in the middle of the right margin
fist_y,
50, // 50 sided
fist_radius,
{
collisionFilter: {
category: collision_fist,
mask: collision_fist | collision_bar | collision_mouse
}
}
);
Composite.add(engine.world, fist);
// Render the Fist
fist_container
.attr("opacity", first ? 1 : 0)
.call(g => g.append('polygon')
.attr("id", `body-${fist.id}`)
.attr("data-body-id", fist.id)
.attr("points", vertices_to_points(fist.vertices))
.classed("plot-data-black", true)
.attr("stroke-width", stroke_width)
// Change the cursor icon while interacting with the fist to make it more
// obvious that you can drag it.
.style("cursor", "grab")
.on("mousedown", function() {
d3.select(this).style("cursor", "grabbing");
// Remove the user-prompt to pick up the fist once they've grabbed it
d3.select("#fist-prompt")
.attr("pointer-events", "none")
.transition()
.duration(1000)
.style("opacity", 0)
.on("end", function() { d3.select(this).remove(); })
})
.on("mouseup", function() {
d3.select(this).style("cursor", "grab");
})
)
.call(g => g.append("text")
.classed("plot-annotation", true)
.attr("id", `body-text-${fist.id}`)
.attr("x", fist.position.x)
.attr("y", fist.position.y)
.attr("text-anchor", "middle") // Centers text horizontally
.attr("dominant-baseline", "middle") // Centers text vertically
.attr("font-size", 16)
.attr("font-weight", 400)
.text("fist")
// Prevents trying to copy/highlight the text, which happens a lot on drag
.style("user-select", "none")
.style("pointer-events", "none")
)
.raise();
if (!first) {
fist_container
.transition()
.duration(duration_bar_fade_in)
.attr("opacity", 1)
}
if (first) {
// Add a label above the fist prompting the user to click on it. We create the
// "fist" later using `create_fist()`.
fist_prompt_container
.call(g => g.append("text")
.classed("plot-annotation", true)
.attr("x", fist.position.x)
.attr("y", fist.position.y - fist_radius - 80)
.attr("text-anchor", "middle")
.attr("font-size", 16)
.attr("font-weight", 400)
.text("Pick Up")
)
.call(g => g.append("text")
.classed("plot-annotation", true)
.attr("x", fist.position.x)
.attr("y", fist.position.y - fist_radius - 80 + 18)
.attr("text-anchor", "middle")
.attr("font-size", 16)
.attr("font-weight", 400)
.text("↓")
)
}
}
// Remove the "fist" physics body and it from the screen
remove_fist = function() {
fist_container
.transition()
.duration(duration_bar_fade_out)
.attr("opacity", 0)
// Remove any child elements of the container (i.e. "fist" and it's label)
// and the corresponding physics body.
.on("end", function() {
const fist_id = d3.select(this).select("polygon").attr("data-body-id");
Composite.remove(engine.world, Composite.get(engine.world, fist_id, "body"));
d3.select(this).selectAll("*").remove();
});
// Remove the "fist" interaction prompt if it still exists
fist_prompt_container
.transition()
.duration(duration_bar_fade_out)
.attr("opacity", 0)
.on("end", function() { d3.select(this).remove(); });
}
// When the category selector is changed, filter the posts and record the
// selection in `previous_selection`.
// mutable click_off = false;
d3.select("#reset-button")
.style("user-select", "none")
.on("click", function() {
// TODO: Think of a much more robust way to prevent spamming of this
// button. I think on each regeneration we should destroy pause the physics
// and then destroy every physics body immediately. Then we un-pause at the
// end of this callback.
// TODO: Nice transition for incrementing the plot title
// Prevent interaction with this button
const button = d3.select(this).style("pointer-events", "none");
// Increment the plot title, fading out and then back in
plot_title
.transition()
.duration(duration_bar_fade_out)
.style("opacity", 0)
.on("end", function() {
plot_title.text(plot_titles[plot_title_index[0]]);
plot_title_index[0] = (plot_title_index[0] + 1) % plot_titles.length;
plot_title.transition().duration(duration_bar_fade_in).style("opacity", 1);
});
// Remove physics bodies
// clear_bodies();
remove_bars();
remove_fist();
// Wait until the end of the `remove_*()` transition to re-generate the
// bars and fist.
svg
.transition()
.delay(duration_bar_fade_out + 100)
.on("end", function() {
create_bars();
create_fist();
});
// Re-activate pointer events using this button
svg
.transition()
.delay(duration_bar_fade_in + duration_bar_fade_out + 500)
.on("end", function() { button.style("pointer-events", "auto"); });
});
function* update() {
while (true) {
// Update the engine
Engine.update(engine, 5)
// TODO: We could remove bodies (via Matter) and polygons (via D3) once
// they've left the screen to get some more performance. Doesn't really
// matter for this demo.
// TODO: Might be fun to add a gravity slider in the future, but I'm
// trying to avoid packing every feature into this plot.
// Move the vertices of every body to their new positions. This is the
// SVG equivalent of Matter's example <canvas> render loop:
// https://github.com/liabru/matter-js/wiki/Rendering
bodies.filter(body => body.label != "ground").forEach(body => {
svg.select(`#body-${body.id}`)
.attr('points', vertices_to_points(body.vertices));
// Rotate any text labels attached to a physics body to match the body's
// rotation. Note that the Matter.js body angles are in radians, but
// `rotate()` expects degrees.
const body_angle = body.angle * (180 / Math.PI);
const body_x = body.position.x;
const body_y = body.position.y;
svg.select(`#body-text-${body.id}`)
.attr("x", body_x)
.attr("y", body_y)
.attr("transform", `rotate(${body_angle}, ${body_x}, ${body_y})`);
})
// Finish
yield
}
}
// Call the `update()` function once to begin the loop
update();