{"spec_id":"cat-box-strip","library":"d3","language":"javascript","code":"// anyplot.ai\n// cat-box-strip: Box Plot with Strip Overlay\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG) + Box-Muller normal ---------------------------\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction randNormal(mean, std) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\n// --- Data: crop yield (kg per plot) under four fertilizer treatments --------\nconst groups = [\n  { category: \"Control\", mean: 28, std: 4.2 },\n  { category: \"Nitrogen\", mean: 34, std: 4.6 },\n  { category: \"Phosphorus\", mean: 31, std: 3.6 },\n  { category: \"Potassium\", mean: 36.5, std: 5.1 },\n];\nconst sampleSize = 70;\nconst data = groups.flatMap((g) =>\n  Array.from({ length: sampleSize }, () => ({\n    category: g.category,\n    value: Math.max(10, randNormal(g.mean, g.std)),\n  }))\n);\n\n// --- Layout -------------------------------------------------------------\nconst margin = { top: 110, right: 60, bottom: 110, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst categories = groups.map((g) => g.category);\nconst color = d3.scaleOrdinal().domain(categories).range(t.palette);\n\nconst x = d3.scaleBand().domain(categories).range([0, iw]).padding(0.35);\nconst yExtent = d3.extent(data, (d) => d.value);\nconst y = d3\n  .scaleLinear()\n  .domain([yExtent[0] * 0.92, yExtent[1] * 1.05])\n  .nice()\n  .range([ih, 0]);\n\n// --- Box stats per category ------------------------------------------------\nconst boxWidth = x.bandwidth() * 0.42;\n\nconst stats = categories.map((category) => {\n  const values = data\n    .filter((d) => d.category === category)\n    .map((d) => d.value)\n    .sort(d3.ascending);\n  const q1 = d3.quantile(values, 0.25);\n  const median = d3.quantile(values, 0.5);\n  const q3 = d3.quantile(values, 0.75);\n  const iqr = q3 - q1;\n  const lowFence = q1 - 1.5 * iqr;\n  const highFence = q3 + 1.5 * iqr;\n  const whiskerLow = d3.min(values.filter((v) => v >= lowFence));\n  const whiskerHigh = d3.max(values.filter((v) => v <= highFence));\n  return { category, q1, median, q3, whiskerLow, whiskerHigh };\n});\n\n// Highest-yield group carries the story of this chart; give it a focal point.\nconst winner = stats.reduce((best, s) => (s.median > best.median ? s : best), stats[0]).category;\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// --- Winner spotlight band (drawn first, sits behind everything) ------------\ng.append(\"rect\")\n  .attr(\"x\", x(winner))\n  .attr(\"y\", 0)\n  .attr(\"width\", x.bandwidth())\n  .attr(\"height\", ih)\n  .attr(\"rx\", 10)\n  .attr(\"fill\", color(winner))\n  .attr(\"fill-opacity\", 0.07);\n\n// --- Y gridlines (subtle, horizontal only) -------------------------------\ng.append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\n\n// --- Strip points (deterministic beeswarm via force simulation, drawn beneath the box) --\nconst pointRadius = 4;\nconst swarmWidth = x.bandwidth() * 0.82;\nconst points = categories.flatMap((category) => {\n  const cx = x(category) + x.bandwidth() / 2;\n  const nodes = data\n    .filter((d) => d.category === category)\n    .map((d) => ({\n      category: d.category,\n      value: d.value,\n      x: cx + (rand() - 0.5) * 4,\n      y: y(d.value),\n      fy: y(d.value),\n    }));\n  const sim = d3\n    .forceSimulation(nodes)\n    .force(\"x\", d3.forceX(cx).strength(0.08))\n    .force(\"collide\", d3.forceCollide(pointRadius + 0.6))\n    .stop();\n  for (let i = 0; i < 200; i++) sim.tick();\n  const lo = cx - swarmWidth / 2;\n  const hi = cx + swarmWidth / 2;\n  for (const n of nodes) n.x = Math.min(hi, Math.max(lo, n.x));\n  return nodes;\n});\ng.selectAll(\"circle\")\n  .data(points)\n  .join(\"circle\")\n  .attr(\"cx\", (d) => d.x)\n  .attr(\"cy\", (d) => d.fy)\n  .attr(\"r\", pointRadius)\n  .attr(\"fill\", (d) => color(d.category))\n  .attr(\"fill-opacity\", 0.4)\n  .attr(\"stroke\", \"none\");\n\n// --- Whiskers ----------------------------------------------------------\nconst whiskerGroup = g.selectAll(\".whisker\").data(stats).join(\"g\").attr(\"class\", \"whisker\");\nconst capWidth = boxWidth * 0.5;\nwhiskerGroup\n  .append(\"line\")\n  .attr(\"x1\", (d) => x(d.category) + x.bandwidth() / 2)\n  .attr(\"x2\", (d) => x(d.category) + x.bandwidth() / 2)\n  .attr(\"y1\", (d) => y(d.whiskerLow))\n  .attr(\"y2\", (d) => y(d.q1))\n  .attr(\"stroke\", (d) => color(d.category))\n  .attr(\"stroke-width\", 2);\nwhiskerGroup\n  .append(\"line\")\n  .attr(\"x1\", (d) => x(d.category) + x.bandwidth() / 2)\n  .attr(\"x2\", (d) => x(d.category) + x.bandwidth() / 2)\n  .attr(\"y1\", (d) => y(d.q3))\n  .attr(\"y2\", (d) => y(d.whiskerHigh))\n  .attr(\"stroke\", (d) => color(d.category))\n  .attr(\"stroke-width\", 2);\nfor (const key of [\"whiskerLow\", \"whiskerHigh\"]) {\n  whiskerGroup\n    .append(\"line\")\n    .attr(\"x1\", (d) => x(d.category) + x.bandwidth() / 2 - capWidth / 2)\n    .attr(\"x2\", (d) => x(d.category) + x.bandwidth() / 2 + capWidth / 2)\n    .attr(\"y1\", (d) => y(d[key]))\n    .attr(\"y2\", (d) => y(d[key]))\n    .attr(\"stroke\", (d) => color(d.category))\n    .attr(\"stroke-width\", 2);\n}\n\n// --- Boxes (translucent so strip points show through) -----------------\ng.selectAll(\".box\")\n  .data(stats)\n  .join(\"rect\")\n  .attr(\"class\", \"box\")\n  .attr(\"x\", (d) => x(d.category) + x.bandwidth() / 2 - boxWidth / 2)\n  .attr(\"y\", (d) => y(d.q3))\n  .attr(\"width\", boxWidth)\n  .attr(\"height\", (d) => y(d.q1) - y(d.q3))\n  .attr(\"fill\", (d) => color(d.category))\n  .attr(\"fill-opacity\", (d) => (d.category === winner ? 0.22 : 0.16))\n  .attr(\"stroke\", (d) => color(d.category))\n  .attr(\"stroke-width\", (d) => (d.category === winner ? 3.5 : 2.5));\n\n// --- Winner annotation (points the eye at the highest-yield group) ---------\nconst winnerStats = stats.find((d) => d.category === winner);\nconst winnerCx = x(winner) + x.bandwidth() / 2;\ng.append(\"text\")\n  .attr(\"x\", winnerCx)\n  .attr(\"y\", y(winnerStats.q3) - 16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", color(winner))\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Highest yield\");\n\n// --- Median lines --------------------------------------------------------\ng.selectAll(\".median\")\n  .data(stats)\n  .join(\"line\")\n  .attr(\"class\", \"median\")\n  .attr(\"x1\", (d) => x(d.category) + x.bandwidth() / 2 - boxWidth / 2)\n  .attr(\"x2\", (d) => x(d.category) + x.bandwidth() / 2 + boxWidth / 2)\n  .attr(\"y1\", (d) => y(d.median))\n  .attr(\"y2\", (d) => y(d.median))\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 3);\n\n// --- Axes ------------------------------------------------------------------\n// y ticks drop their stub (tickSize 0) since the gridlines already mark them;\n// x keeps a short stub since it has no gridline counterpart.\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).tickSize(4));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6).tickSize(0));\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.inkSoft);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1);\n}\n\n// --- Axis labels -------------------------------------------------------------\n// Sized below the 18px title so the bold title reads as clearly dominant.\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 80)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"15px\")\n  .text(\"Fertilizer Treatment (n = 70 per group)\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -70)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"15px\")\n  .text(\"Crop Yield (kg per plot)\");\n\n// --- Title -----------------------------------------------------------------\n// title = \"Crop Yield by Fertilizer Treatment · cat-box-strip · javascript · d3 · anyplot.ai\" (81 chars)\n// fontsize = round(22 * 67/81) = 18px, per plot-generator.md title-length scaling formula\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Crop Yield by Fertilizer Treatment · cat-box-strip · javascript · d3 · anyplot.ai\");\n"}