{"spec_id":"box-notched","library":"d3","language":"javascript","code":"// anyplot.ai\n// box-notched: Notched Box Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-18\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 100, right: 60, bottom: 90, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Deterministic PRNG (LCG + Box-Muller) — browser has no seeded RNG ------\nlet seed = 42;\nfunction uniform() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction normal(mean, std) {\n  const u1 = Math.max(uniform(), 1e-9);\n  const u2 = uniform();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\n// --- Data: grain yield (bushels/acre) across fertilizer treatments ---------\nconst groups = [\n  { label: \"Control\", mean: 148, std: 18, n: 42 },\n  { label: \"Nitrogen\", mean: 162, std: 20, n: 55 },\n  { label: \"Phosphorus\", mean: 155, std: 16, n: 48 },\n  { label: \"Potassium\", mean: 151, std: 19, n: 50 },\n  { label: \"NPK Blend\", mean: 179, std: 22, n: 60 },\n];\n\nconst stats = groups.map((group) => {\n  const values = Array.from({ length: group.n }, () => normal(group.mean, group.std));\n  const sorted = values.slice().sort(d3.ascending);\n  const q1 = d3.quantileSorted(sorted, 0.25);\n  const median = d3.quantileSorted(sorted, 0.5);\n  const q3 = d3.quantileSorted(sorted, 0.75);\n  const iqr = q3 - q1;\n  const lowFence = q1 - 1.5 * iqr;\n  const highFence = q3 + 1.5 * iqr;\n  const inFence = sorted.filter((v) => v >= lowFence && v <= highFence);\n  const outliers = sorted.filter((v) => v < lowFence || v > highFence);\n  const notchHalf = (1.57 * iqr) / Math.sqrt(group.n);\n  return {\n    label: group.label,\n    q1,\n    median,\n    q3,\n    whiskerLow: d3.min(inFence),\n    whiskerHigh: d3.max(inFence),\n    outliers,\n    notchTop: Math.min(median + notchHalf, q3),\n    notchBottom: Math.max(median - notchHalf, q1),\n  };\n});\n\n// --- Notch-overlap check — the entire point of a notched box plot: when two\n// notches don't overlap, the medians differ significantly (95% CI). Find the\n// most extreme non-overlapping pair to call out with a bracket annotation.\nconst notchOverlaps = (a, b) => a.notchBottom <= b.notchTop && b.notchBottom <= a.notchTop;\nconst nonOverlappingPairs = d3\n  .cross(stats, stats)\n  .filter(([a, b]) => a.label < b.label && !notchOverlaps(a, b));\nconst sigPair = d3.greatest(nonOverlappingPairs, ([a, b]) => Math.abs(a.median - b.median));\n\n// --- Scales -------------------------------------------------------------\nconst x = d3.scaleBand().domain(stats.map((d) => d.label)).range([0, iw]).padding(0.35);\nconst allValues = stats.flatMap((d) => [d.whiskerLow, d.whiskerHigh, ...d.outliers]);\nconst annotationPad = sigPair ? 55 : 0;\nconst y = d3.scaleLinear().domain(d3.extent(allValues)).nice().range([ih, annotationPad]);\nconst color = d3.scaleOrdinal().domain(stats.map((d) => d.label)).range(t.palette);\nconst boxWidth = x.bandwidth() * 0.7;\nconst notchInset = boxWidth * 0.28;\nconst capWidth = boxWidth * 0.4;\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// --- Gridlines (y-axis only, behind the boxes) -----------------------------\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(y.ticks(6))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- Axes -------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6));\nfor (const axis of [xAxis, yAxis]) {\n  axis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\");\n  axis.selectAll(\".tick line\").remove();\n  axis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Box groups ---------------------------------------------------------\nconst boxGroups = g\n  .selectAll(\".box-group\")\n  .data(stats)\n  .join(\"g\")\n  .attr(\"class\", \"box-group\")\n  .attr(\"transform\", (d) => `translate(${x(d.label) + x.bandwidth() / 2},0)`);\n\n// Whiskers\nboxGroups\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 0)\n  .attr(\"y1\", (d) => y(d.q3))\n  .attr(\"y2\", (d) => y(d.whiskerHigh))\n  .attr(\"stroke\", (d) => color(d.label))\n  .attr(\"stroke-width\", 2);\nboxGroups\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 0)\n  .attr(\"y1\", (d) => y(d.q1))\n  .attr(\"y2\", (d) => y(d.whiskerLow))\n  .attr(\"stroke\", (d) => color(d.label))\n  .attr(\"stroke-width\", 2);\n\n// Whisker caps\nboxGroups\n  .append(\"line\")\n  .attr(\"x1\", -capWidth / 2)\n  .attr(\"x2\", capWidth / 2)\n  .attr(\"y1\", (d) => y(d.whiskerHigh))\n  .attr(\"y2\", (d) => y(d.whiskerHigh))\n  .attr(\"stroke\", (d) => color(d.label))\n  .attr(\"stroke-width\", 2);\nboxGroups\n  .append(\"line\")\n  .attr(\"x1\", -capWidth / 2)\n  .attr(\"x2\", capWidth / 2)\n  .attr(\"y1\", (d) => y(d.whiskerLow))\n  .attr(\"y2\", (d) => y(d.whiskerLow))\n  .attr(\"stroke\", (d) => color(d.label))\n  .attr(\"stroke-width\", 2);\n\n// Notched box — pinched inward at the median to show the 95% CI\nfunction boxPath(d) {\n  const x0 = -boxWidth / 2;\n  const x1 = boxWidth / 2;\n  const yQ3 = y(d.q3);\n  const yQ1 = y(d.q1);\n  const yNTop = y(d.notchTop);\n  const yNBot = y(d.notchBottom);\n  const yMed = y(d.median);\n  return `M${x0},${yQ3}\n    L${x1},${yQ3}\n    L${x1},${yNTop}\n    L${x1 - notchInset},${yMed}\n    L${x1},${yNBot}\n    L${x1},${yQ1}\n    L${x0},${yQ1}\n    L${x0},${yNBot}\n    L${x0 + notchInset},${yMed}\n    L${x0},${yNTop}\n    Z`;\n}\n\nboxGroups\n  .append(\"path\")\n  .attr(\"d\", boxPath)\n  .attr(\"fill\", (d) => color(d.label))\n  .attr(\"fill-opacity\", 0.28)\n  .attr(\"stroke\", (d) => color(d.label))\n  .attr(\"stroke-width\", 2.5)\n  .attr(\"stroke-linejoin\", \"round\");\n\n// Median line across the notch waist\nboxGroups\n  .append(\"line\")\n  .attr(\"x1\", -boxWidth / 2 + notchInset)\n  .attr(\"x2\", boxWidth / 2 - notchInset)\n  .attr(\"y1\", (d) => y(d.median))\n  .attr(\"y2\", (d) => y(d.median))\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 2.5);\n\n// Outliers — hollow markers beyond the whiskers\nboxGroups.each(function (d) {\n  d3.select(this)\n    .selectAll(\".outlier\")\n    .data(d.outliers)\n    .join(\"circle\")\n    .attr(\"class\", \"outlier\")\n    .attr(\"cx\", 0)\n    .attr(\"cy\", (v) => y(v))\n    .attr(\"r\", 4.5)\n    .attr(\"fill\", t.pageBg)\n    .attr(\"stroke\", color(d.label))\n    .attr(\"stroke-width\", 1.75);\n});\n\n// --- Significance bracket — makes the \"non-overlapping notches = significant\n// difference\" insight explicit instead of relying on the reader to spot it ---\nif (sigPair) {\n  const [a, b] = sigPair;\n  const cx = (d) => x(d.label) + x.bandwidth() / 2;\n  const topExtent = (d) => d3.max([d.whiskerHigh, ...d.outliers]);\n  const bracketY = annotationPad - 22;\n  const xa = cx(a);\n  const xb = cx(b);\n\n  const bracket = d3.line().curve(d3.curveLinear);\n  g.append(\"path\")\n    .datum([\n      [xa, y(topExtent(a)) - 10],\n      [xa, bracketY],\n      [xb, bracketY],\n      [xb, y(topExtent(b)) - 10],\n    ])\n    .attr(\"d\", bracket)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1.5);\n\n  g.append(\"text\")\n    .attr(\"x\", (xa + xb) / 2)\n    .attr(\"y\", bracketY - 8)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .style(\"font-style\", \"italic\")\n    .text(\"non-overlapping notches → medians differ significantly\");\n}\n\n// --- Axis titles --------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 62)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"Fertilizer Treatment\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -68)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"Grain Yield (bushels/acre)\");\n\n// --- Title — fontsize scales linearly off the 67-char mandated baseline ----\nconst title = \"Grain Yield by Fertilizer Treatment · box-notched · javascript · d3 · anyplot.ai\";\nconst titleRatio = title.length > 67 ? 67 / title.length : 1;\nconst titleFontSize = Math.max(14, Math.round(22 * titleRatio));\n\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\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n"}