Density Plot with Rug Marks — D3.js

A kernel density estimation (KDE) plot combined with rug marks along the x-axis, showing both the smoothed probability distribution and the exact location of each individual data point. This combination provides the best of both worlds: the KDE reveals the overall shape, modality, and smoothed density of the distribution, while the rug marks preserve transparency about where actual observations fall, highlighting data density and potential gaps.

Density Plot with Rug Marks rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// density-rug: Density Plot with Rug Marks
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 92/100 | Created: 2026-09-02

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 90, right: 70, bottom: 110, left: 110 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data (in-memory, deterministic) ----------------------------------------
// Reaction times (ms) pooled from two task-difficulty conditions, producing a
// mildly bimodal distribution — the kind of shape a KDE reveals but a
// histogram alone can obscure.
function lcg(seed) {
  let s = seed % 2147483647;
  if (s <= 0) s += 2147483646;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s - 1) / 2147483646;
  };
}
function randomNormal(rng, mean, std) {
  let u = 0;
  let v = 0;
  while (u === 0) u = rng();
  while (v === 0) v = rng();
  return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}

const rng = lcg(42);
const easyTrials = Array.from({ length: 130 }, () => randomNormal(rng, 320, 36));
const hardTrials = Array.from({ length: 75 }, () => randomNormal(rng, 495, 50));
const reactionTimes = easyTrials.concat(hardTrials).filter((v) => v > 150 && v < 700);

// --- Kernel density estimate --------------------------------------------------
function kernelGaussian(bandwidth) {
  return (v) => Math.exp(-0.5 * (v / bandwidth) ** 2) / (bandwidth * Math.sqrt(2 * Math.PI));
}
function kernelDensityEstimator(kernel, sampleX) {
  return (sampleValues) => sampleX.map((xi) => [xi, d3.mean(sampleValues, (v) => kernel(xi - v))]);
}

const [dataMin, dataMax] = d3.extent(reactionTimes);
const domainPad = (dataMax - dataMin) * 0.08;

const x = d3.scaleLinear().domain([dataMin - domainPad, dataMax + domainPad]).nice().range([0, iw]);

// Silverman's rule of thumb for bandwidth selection.
const bandwidth = 1.06 * d3.deviation(reactionTimes) * Math.pow(reactionTimes.length, -0.2);
const density = kernelDensityEstimator(kernelGaussian(bandwidth), x.ticks(300))(reactionTimes);

const y = d3.scaleLinear().domain([0, d3.max(density, (d) => d[1]) * 1.12]).range([ih, 0]);

// The two reaction-time regimes (fast vs. slow trials) each carve out a local
// maximum in the KDE; find them so the chart can call them out directly
// instead of leaving the bimodality as a shape the viewer has to notice alone.
function findTwoPeaks(points, minSeparation) {
  const byDensity = points.slice().sort((a, b) => b[1] - a[1]);
  const first = byDensity[0];
  const second = byDensity.find((d) => Math.abs(d[0] - first[0]) > minSeparation);
  return [first, second].sort((a, b) => a[0] - b[0]);
}
const [fastPeak, slowPeak] = findTwoPeaks(density, (dataMax - dataMin) * 0.15);

// --- SVG mount ----------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Axes -----------------------------------------------------------------
const xAxis = g
  .append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(d3.axisBottom(x).ticks(8));
const yAxis = g.append("g").call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(".3f")));
for (const ax of [xAxis, yAxis]) {
  ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "16px");
  ax.selectAll("line").attr("stroke", t.grid);
  ax.select(".domain").attr("stroke", t.inkSoft);
}

// --- Y-axis gridlines (subtle) ----------------------------------------------
g.append("g")
  .attr("class", "grid")
  .call(d3.axisLeft(y).ticks(5).tickSize(-iw).tickFormat(""))
  .call((sel) => sel.select(".domain").remove())
  .call((sel) => sel.selectAll("line").attr("stroke", t.grid));

// --- KDE curve: gradient-filled area + line ----------------------------------
// A vertical fade (denser green at the baseline, airier near the peak) gives
// the fill more depth than a single flat fill-opacity, within the Imprint hue.
const gradientId = "density-fill-gradient";
svg
  .append("defs")
  .append("linearGradient")
  .attr("id", gradientId)
  .attr("x1", "0")
  .attr("x2", "0")
  .attr("y1", "0")
  .attr("y2", "1")
  .call((grad) => grad.append("stop").attr("offset", "0%").attr("stop-color", t.palette[0]).attr("stop-opacity", 0.08))
  .call((grad) => grad.append("stop").attr("offset", "100%").attr("stop-color", t.palette[0]).attr("stop-opacity", 0.4));

const area = d3
  .area()
  .x((d) => x(d[0]))
  .y0(ih)
  .y1((d) => y(d[1]))
  .curve(d3.curveBasis);
const line = d3
  .line()
  .x((d) => x(d[0]))
  .y((d) => y(d[1]))
  .curve(d3.curveBasis);

g.append("path").datum(density).attr("d", area).attr("fill", `url(#${gradientId})`);
g.append("path")
  .datum(density)
  .attr("d", line)
  .attr("fill", "none")
  .attr("stroke", t.palette[0])
  .attr("stroke-width", 3.5);

// --- Peak annotations: name the two reaction-time regimes --------------------
const peakLabels = [
  { peak: fastPeak, text: "Fast trials" },
  { peak: slowPeak, text: "Slow trials" },
];
g.selectAll(".peak-marker")
  .data(peakLabels)
  .join("circle")
  .attr("class", "peak-marker")
  .attr("cx", (d) => x(d.peak[0]))
  .attr("cy", (d) => y(d.peak[1]))
  .attr("r", 4.5)
  .attr("fill", t.palette[0]);
g.selectAll(".peak-label")
  .data(peakLabels)
  .join("text")
  .attr("class", "peak-label")
  .attr("x", (d) => x(d.peak[0]))
  .attr("y", (d) => y(d.peak[1]) - 16)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "15px")
  .style("font-style", "italic")
  .text((d) => d.text);

// --- Rug marks: exact observation locations along the x-axis -----------------
// Short ticks placed at a deterministically jittered vertical position within
// the rug band (the spec's own suggestion) instead of one line per point
// spanning the full band — this staggers observations that share nearly the
// same x pixel so they read as distinct marks instead of a solid dark block.
const rugBandHeight = 22;
const tickLength = 6;
const jitterRng = lcg(7);
const rugData = reactionTimes.map((value) => ({ value, jitter: jitterRng() }));
g.append("g")
  .selectAll("line")
  .data(rugData)
  .join("line")
  .attr("x1", (d) => x(d.value))
  .attr("x2", (d) => x(d.value))
  .attr("y1", (d) => ih - d.jitter * (rugBandHeight - tickLength))
  .attr("y2", (d) => ih - d.jitter * (rugBandHeight - tickLength) - tickLength)
  .attr("stroke", t.palette[0])
  .attr("stroke-width", 1.5)
  .attr("stroke-opacity", 0.4);

// --- Axis labels --------------------------------------------------------------
svg
  .append("text")
  .attr("x", margin.left + iw / 2)
  .attr("y", height - 30)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "20px")
  .text("Reaction Time (ms)");

svg
  .append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -(margin.top + ih / 2))
  .attr("y", 40)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "20px")
  .text("Density");

// --- Title ----------------------------------------------------------------
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 48)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "28px")
  .style("font-weight", "600")
  .text("density-rug · javascript · d3 · anyplot.ai");

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/density-rug/d3/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "density-rug",
  "language": "javascript",
  "library": "d3",
  "page": "https://anyplot.ai/density-rug/javascript/d3",
  "hub": "https://anyplot.ai/density-rug",
  "code_json": "https://api.anyplot.ai/specs/density-rug/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/density-rug",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/density-rug/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/density-rug/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/density-rug/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/density-rug/javascript/d3/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Density Plot with Rug Marks on anyplot.ai.

Other implementations