{"spec_id":"density-rug","library":"d3","language":"javascript","code":"// anyplot.ai\n// density-rug: Density Plot with Rug Marks\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 90, right: 70, bottom: 110, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Reaction times (ms) pooled from two task-difficulty conditions, producing a\n// mildly bimodal distribution — the kind of shape a KDE reveals but a\n// histogram alone can obscure.\nfunction lcg(seed) {\n  let s = seed % 2147483647;\n  if (s <= 0) s += 2147483646;\n  return () => {\n    s = (s * 16807) % 2147483647;\n    return (s - 1) / 2147483646;\n  };\n}\nfunction randomNormal(rng, mean, std) {\n  let u = 0;\n  let v = 0;\n  while (u === 0) u = rng();\n  while (v === 0) v = rng();\n  return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);\n}\n\nconst rng = lcg(42);\nconst easyTrials = Array.from({ length: 130 }, () => randomNormal(rng, 320, 36));\nconst hardTrials = Array.from({ length: 75 }, () => randomNormal(rng, 495, 50));\nconst reactionTimes = easyTrials.concat(hardTrials).filter((v) => v > 150 && v < 700);\n\n// --- Kernel density estimate --------------------------------------------------\nfunction kernelGaussian(bandwidth) {\n  return (v) => Math.exp(-0.5 * (v / bandwidth) ** 2) / (bandwidth * Math.sqrt(2 * Math.PI));\n}\nfunction kernelDensityEstimator(kernel, sampleX) {\n  return (sampleValues) => sampleX.map((xi) => [xi, d3.mean(sampleValues, (v) => kernel(xi - v))]);\n}\n\nconst [dataMin, dataMax] = d3.extent(reactionTimes);\nconst domainPad = (dataMax - dataMin) * 0.08;\n\nconst x = d3.scaleLinear().domain([dataMin - domainPad, dataMax + domainPad]).nice().range([0, iw]);\n\n// Silverman's rule of thumb for bandwidth selection.\nconst bandwidth = 1.06 * d3.deviation(reactionTimes) * Math.pow(reactionTimes.length, -0.2);\nconst density = kernelDensityEstimator(kernelGaussian(bandwidth), x.ticks(300))(reactionTimes);\n\nconst y = d3.scaleLinear().domain([0, d3.max(density, (d) => d[1]) * 1.12]).range([ih, 0]);\n\n// The two reaction-time regimes (fast vs. slow trials) each carve out a local\n// maximum in the KDE; find them so the chart can call them out directly\n// instead of leaving the bimodality as a shape the viewer has to notice alone.\nfunction findTwoPeaks(points, minSeparation) {\n  const byDensity = points.slice().sort((a, b) => b[1] - a[1]);\n  const first = byDensity[0];\n  const second = byDensity.find((d) => Math.abs(d[0] - first[0]) > minSeparation);\n  return [first, second].sort((a, b) => a[0] - b[0]);\n}\nconst [fastPeak, slowPeak] = findTwoPeaks(density, (dataMax - dataMin) * 0.15);\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Axes -----------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(8));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(\".3f\")));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Y-axis gridlines (subtle) ----------------------------------------------\ng.append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(y).ticks(5).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .call((sel) => sel.selectAll(\"line\").attr(\"stroke\", t.grid));\n\n// --- KDE curve: gradient-filled area + line ----------------------------------\n// A vertical fade (denser green at the baseline, airier near the peak) gives\n// the fill more depth than a single flat fill-opacity, within the Imprint hue.\nconst gradientId = \"density-fill-gradient\";\nsvg\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", gradientId)\n  .attr(\"x1\", \"0\")\n  .attr(\"x2\", \"0\")\n  .attr(\"y1\", \"0\")\n  .attr(\"y2\", \"1\")\n  .call((grad) => grad.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", t.palette[0]).attr(\"stop-opacity\", 0.08))\n  .call((grad) => grad.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", t.palette[0]).attr(\"stop-opacity\", 0.4));\n\nconst area = d3\n  .area()\n  .x((d) => x(d[0]))\n  .y0(ih)\n  .y1((d) => y(d[1]))\n  .curve(d3.curveBasis);\nconst line = d3\n  .line()\n  .x((d) => x(d[0]))\n  .y((d) => y(d[1]))\n  .curve(d3.curveBasis);\n\ng.append(\"path\").datum(density).attr(\"d\", area).attr(\"fill\", `url(#${gradientId})`);\ng.append(\"path\")\n  .datum(density)\n  .attr(\"d\", line)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 3.5);\n\n// --- Peak annotations: name the two reaction-time regimes --------------------\nconst peakLabels = [\n  { peak: fastPeak, text: \"Fast trials\" },\n  { peak: slowPeak, text: \"Slow trials\" },\n];\ng.selectAll(\".peak-marker\")\n  .data(peakLabels)\n  .join(\"circle\")\n  .attr(\"class\", \"peak-marker\")\n  .attr(\"cx\", (d) => x(d.peak[0]))\n  .attr(\"cy\", (d) => y(d.peak[1]))\n  .attr(\"r\", 4.5)\n  .attr(\"fill\", t.palette[0]);\ng.selectAll(\".peak-label\")\n  .data(peakLabels)\n  .join(\"text\")\n  .attr(\"class\", \"peak-label\")\n  .attr(\"x\", (d) => x(d.peak[0]))\n  .attr(\"y\", (d) => y(d.peak[1]) - 16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"15px\")\n  .style(\"font-style\", \"italic\")\n  .text((d) => d.text);\n\n// --- Rug marks: exact observation locations along the x-axis -----------------\n// Short ticks placed at a deterministically jittered vertical position within\n// the rug band (the spec's own suggestion) instead of one line per point\n// spanning the full band — this staggers observations that share nearly the\n// same x pixel so they read as distinct marks instead of a solid dark block.\nconst rugBandHeight = 22;\nconst tickLength = 6;\nconst jitterRng = lcg(7);\nconst rugData = reactionTimes.map((value) => ({ value, jitter: jitterRng() }));\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(rugData)\n  .join(\"line\")\n  .attr(\"x1\", (d) => x(d.value))\n  .attr(\"x2\", (d) => x(d.value))\n  .attr(\"y1\", (d) => ih - d.jitter * (rugBandHeight - tickLength))\n  .attr(\"y2\", (d) => ih - d.jitter * (rugBandHeight - tickLength) - tickLength)\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-opacity\", 0.4);\n\n// --- Axis labels --------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 30)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"20px\")\n  .text(\"Reaction Time (ms)\");\n\nsvg\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(margin.top + ih / 2))\n  .attr(\"y\", 40)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"20px\")\n  .text(\"Density\");\n\n// --- Title ----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"28px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"density-rug · javascript · d3 · anyplot.ai\");\n"}