{"spec_id":"box-grouped","library":"d3","language":"javascript","code":"// anyplot.ai\n// box-grouped: Grouped Box Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 96/100 | Created: 2026-08-18\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 150, right: 60, 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-time study: task type x age group, 70 trials per cell.\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\nfunction randNormal(mean, sd) {\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 * sd;\n}\n\nconst taskTypes = [\"Visual\", \"Auditory\", \"Tactile\", \"Combined\"];\nconst ageGroups = [\"Young (18-30)\", \"Middle (31-50)\", \"Older (51-70)\"];\nconst baseMean = { Visual: 320, Auditory: 280, Tactile: 350, Combined: 420 };\nconst ageOffset = { \"Young (18-30)\": 0, \"Middle (31-50)\": 40, \"Older (51-70)\": 90 };\nconst trialsPerCell = 70;\n\nconst cells = [];\nfor (const task of taskTypes) {\n  for (const age of ageGroups) {\n    const mean = baseMean[task] + ageOffset[age];\n    const values = Array.from({ length: trialsPerCell }, () =>\n      Math.max(120, randNormal(mean, 45)),\n    ).sort((a, b) => a - b);\n\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 lowerFence = q1 - 1.5 * iqr;\n    const upperFence = q3 + 1.5 * iqr;\n    const inliers = values.filter((v) => v >= lowerFence && v <= upperFence);\n    const outliers = values.filter((v) => v < lowerFence || v > upperFence);\n\n    cells.push({\n      task,\n      age,\n      q1,\n      median,\n      q3,\n      whiskerLow: d3.min(inliers),\n      whiskerHigh: d3.max(inliers),\n      outliers,\n    });\n  }\n}\n\nconst allValues = cells.flatMap((c) => [c.whiskerLow, c.whiskerHigh, ...c.outliers]);\nconst valueExtent = d3.extent(allValues);\nconst valuePad = (valueExtent[1] - valueExtent[0]) * 0.08;\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// --- Scales ---------------------------------------------------------------------\nconst x0 = d3.scaleBand().domain(taskTypes).range([0, iw]).paddingInner(0.35).paddingOuter(0.15);\nconst x1 = d3.scaleBand().domain(ageGroups).range([0, x0.bandwidth()]).padding(0.25);\nconst y = d3\n  .scaleLinear()\n  .domain([valueExtent[0] - valuePad, valueExtent[1] + valuePad])\n  .nice()\n  .range([ih, 0]);\nconst color = d3.scaleOrdinal().domain(ageGroups).range(t.palette.slice(0, ageGroups.length));\n\n// --- Gridlines --------------------------------------------------------------\ng.append(\"g\")\n  .call(d3.axisLeft(y).tickSize(-iw).tickFormat(\"\"))\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\ng.select(\".domain\").remove();\n\n// --- Axes ---------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x0));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6).tickFormat((d) => `${d} ms`));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Axis labels --------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 66)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Task Type\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -78)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Reaction Time (ms)\");\n\n// --- Grouped boxes --------------------------------------------------------------\nconst boxWidth = x1.bandwidth();\nconst capWidth = boxWidth * 0.5;\n\nconst group = g\n  .selectAll(\".cell\")\n  .data(cells)\n  .join(\"g\")\n  .attr(\"class\", \"cell\")\n  .attr(\"transform\", (d) => `translate(${x0(d.task) + x1(d.age)},0)`);\n\n// Whiskers\ngroup\n  .append(\"line\")\n  .attr(\"x1\", boxWidth / 2)\n  .attr(\"x2\", boxWidth / 2)\n  .attr(\"y1\", (d) => y(d.whiskerLow))\n  .attr(\"y2\", (d) => y(d.q1))\n  .attr(\"stroke\", (d) => color(d.age))\n  .attr(\"stroke-width\", 2);\ngroup\n  .append(\"line\")\n  .attr(\"x1\", boxWidth / 2)\n  .attr(\"x2\", boxWidth / 2)\n  .attr(\"y1\", (d) => y(d.q3))\n  .attr(\"y2\", (d) => y(d.whiskerHigh))\n  .attr(\"stroke\", (d) => color(d.age))\n  .attr(\"stroke-width\", 2);\n\n// Whisker caps\nfor (const key of [\"whiskerLow\", \"whiskerHigh\"]) {\n  group\n    .append(\"line\")\n    .attr(\"x1\", (boxWidth - capWidth) / 2)\n    .attr(\"x2\", (boxWidth + capWidth) / 2)\n    .attr(\"y1\", (d) => y(d[key]))\n    .attr(\"y2\", (d) => y(d[key]))\n    .attr(\"stroke\", (d) => color(d.age))\n    .attr(\"stroke-width\", 2);\n}\n\n// Box (Q1-Q3)\ngroup\n  .append(\"rect\")\n  .attr(\"x\", 0)\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.age))\n  .attr(\"fill-opacity\", 0.55)\n  .attr(\"stroke\", (d) => color(d.age))\n  .attr(\"stroke-width\", 2);\n\n// Median line\ngroup\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", boxWidth)\n  .attr(\"y1\", (d) => y(d.median))\n  .attr(\"y2\", (d) => y(d.median))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 3);\n\n// Outliers — cluster near-duplicate values within the same box get a small\n// deterministic horizontal spread so individual points stay separable.\nconst yDomainSpan = y.domain()[1] - y.domain()[0];\nconst clusterThreshold = yDomainSpan * 0.01;\nfunction spreadOutliers(values) {\n  const sorted = [...values].sort((a, b) => a - b);\n  const dx = new Array(sorted.length).fill(0);\n  let clusterStart = 0;\n  for (let i = 1; i <= sorted.length; i++) {\n    const closeToPrev = i < sorted.length && sorted[i] - sorted[i - 1] < clusterThreshold;\n    if (!closeToPrev) {\n      const clusterSize = i - clusterStart;\n      if (clusterSize > 1) {\n        for (let j = clusterStart; j < i; j++) {\n          dx[j] = (j - clusterStart - (clusterSize - 1) / 2) * 6;\n        }\n      }\n      clusterStart = i;\n    }\n  }\n  return sorted.map((value, i) => ({ value, dx: dx[i] }));\n}\n\ngroup\n  .selectAll(\".outlier\")\n  .data((d) => spreadOutliers(d.outliers).map(({ value, dx }) => ({ value, dx, age: d.age })))\n  .join(\"circle\")\n  .attr(\"class\", \"outlier\")\n  .attr(\"cx\", (d) => boxWidth / 2 + d.dx)\n  .attr(\"cy\", (d) => y(d.value))\n  .attr(\"r\", 4.5)\n  .attr(\"fill\", (d) => color(d.age))\n  .attr(\"fill-opacity\", 0.85)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1);\n\n// --- Age-gap callout: highlight the task type with the largest Young→Older shift ---\nconst youngAge = ageGroups[0];\nconst olderAge = ageGroups[ageGroups.length - 1];\nconst gapByTask = taskTypes.map((task) => {\n  const young = cells.find((c) => c.task === task && c.age === youngAge);\n  const older = cells.find((c) => c.task === task && c.age === olderAge);\n  return { task, young, older, gap: older.median - young.median };\n});\nconst widestGap = gapByTask.reduce((a, b) => (b.gap > a.gap ? b : a));\nconst gapYoungX = x0(widestGap.task) + x1(youngAge) + boxWidth / 2;\nconst gapOlderX = x0(widestGap.task) + x1(olderAge) + boxWidth / 2;\nconst gapY = Math.min(y(widestGap.young.q3), y(widestGap.older.q3)) - 16;\n\nconst callout = g.append(\"g\").attr(\"class\", \"age-gap-callout\");\ncallout\n  .append(\"line\")\n  .attr(\"x1\", gapYoungX)\n  .attr(\"x2\", gapOlderX)\n  .attr(\"y1\", gapY)\n  .attr(\"y2\", gapY)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1)\n  .attr(\"stroke-dasharray\", \"3,2\");\nfor (const cx of [gapYoungX, gapOlderX]) {\n  callout\n    .append(\"line\")\n    .attr(\"x1\", cx)\n    .attr(\"x2\", cx)\n    .attr(\"y1\", gapY)\n    .attr(\"y2\", gapY + 6)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1);\n}\ncallout\n  .append(\"text\")\n  .attr(\"x\", (gapYoungX + gapOlderX) / 2)\n  .attr(\"y\", gapY - 7)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\")\n  .style(\"font-style\", \"italic\")\n  .text(`+${Math.round(widestGap.gap)} ms largest age gap`);\n\n// --- Legend ---------------------------------------------------------------------\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(0, 96)`);\nlet xOffset = 0;\nfor (const age of ageGroups) {\n  const item = legend.append(\"g\").attr(\"transform\", `translate(${xOffset},0)`);\n  item.append(\"rect\").attr(\"width\", 18).attr(\"height\", 18).attr(\"y\", -13).attr(\"rx\", 3).attr(\"fill\", color(age));\n  const label = item\n    .append(\"text\")\n    .attr(\"x\", 26)\n    .attr(\"y\", 0)\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"14px\")\n    .text(age);\n  xOffset += 26 + label.node().getBBox().width + 34;\n}\nconst legendWidth = xOffset - 34;\nlegend.attr(\"transform\", `translate(${(width - legendWidth) / 2}, 96)`);\n\n// --- Title ------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"box-grouped · javascript · d3 · anyplot.ai\");\n"}