{"spec_id":"bar-spine","library":"d3","language":"javascript","code":"// anyplot.ai\n// bar-spine: Spine Plot for Two-Variable Proportions\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;\n\n// Theme-adaptive muted anchor (not shipped in ANYPLOT_TOKENS — see default-style-guide.md)\nconst MUTED = t.theme === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\nconst LIGHT_TXT = \"#FFFDF6\";\nconst DARK_TXT = \"#1A1A17\";\n\n// --- WCAG contrast helpers (pick readable in-segment label color) -----------\nconst luminance = (hex) => {\n  const c = hex.replace(\"#\", \"\");\n  const [r, g, b] = [0, 2, 4].map((i) => parseInt(c.substr(i, 2), 16) / 255);\n  const lin = (v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));\n  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n};\nconst contrast = (hexA, hexB) => {\n  const [la, lb] = [luminance(hexA), luminance(hexB)];\n  const [hi, lo] = la > lb ? [la, lb] : [lb, la];\n  return (hi + 0.05) / (lo + 0.05);\n};\nconst textColorFor = (bgHex) =>\n  contrast(bgHex, DARK_TXT) >= contrast(bgHex, LIGHT_TXT) ? DARK_TXT : LIGHT_TXT;\n\n// --- Data: clinical trial outcome by dosage group (in-memory, deterministic) -\nconst data = [\n  { category: \"Placebo\", n: 120, counts: { Improved: 24, \"No Change\": 60, Worsened: 36 } },\n  { category: \"Low Dose\", n: 95, counts: { Improved: 43, \"No Change\": 38, Worsened: 14 } },\n  { category: \"Medium Dose\", n: 150, counts: { Improved: 98, \"No Change\": 37, Worsened: 15 } },\n  { category: \"High Dose\", n: 80, counts: { Improved: 56, \"No Change\": 16, Worsened: 8 } },\n];\n\n// Improved/No Change/Worsened read as good/neutral/bad — semantic exception (default-style-guide.md)\nconst fillKeys = [\"Improved\", \"No Change\", \"Worsened\"];\nconst fillColor = { Improved: t.palette[0], \"No Change\": MUTED, Worsened: t.palette[4] };\n\n// --- Layout --------------------------------------------------------------\nconst margin = { top: 155, right: 60, bottom: 115, left: 90 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// Bar width proportional to marginal count n; segments stacked to 100 %.\nconst grandTotal = d3.sum(data, (d) => d.n);\nlet cursor = 0;\nconst bars = data.map((d) => {\n  const barWidth = (d.n / grandTotal) * iw;\n  const x0 = cursor;\n  cursor += barWidth;\n  let stack = 0;\n  const segments = fillKeys.map((key) => {\n    const pct = (d.counts[key] / d.n) * 100;\n    const seg = { key, pct, y0: stack, y1: stack + pct };\n    stack += pct;\n    return seg;\n  });\n  return { ...d, x0, barWidth, segments };\n});\n\nconst y = d3.scaleLinear().domain([0, 100]).range([ih, 0]);\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// --- Y-axis (cumulative share) + gridlines ----------------------------------\nconst yAxis = g\n  .append(\"g\")\n  .call(\n    d3\n      .axisLeft(y)\n      .tickValues([0, 25, 50, 75, 100])\n      .tickFormat((v) => `${v}%`)\n      .tickSize(-iw)\n  );\nyAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").attr(\"x\", -10);\nyAxis.selectAll(\"line\").attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\nyAxis.select(\".domain\").remove();\n\ng.append(\"text\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -62)\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Share of group\");\n\n// --- Spine bars --------------------------------------------------------------\nconst barGroups = g\n  .selectAll(\".bar\")\n  .data(bars)\n  .join(\"g\")\n  .attr(\"transform\", (d) => `translate(${d.x0},0)`);\n\n// Segments + in-segment percentage labels (barWidth is per-group, so both are\n// drawn together in one pass over the bound bar datum).\nbarGroups.each(function (bar) {\n  const group = d3.select(this);\n  group\n    .selectAll(\"rect\")\n    .data(bar.segments)\n    .join(\"rect\")\n    .attr(\"x\", 0)\n    .attr(\"y\", (seg) => y(seg.y1))\n    .attr(\"width\", bar.barWidth)\n    .attr(\"height\", (seg) => y(seg.y0) - y(seg.y1))\n    .attr(\"fill\", (seg) => fillColor[seg.key])\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 1.5);\n\n  bar.segments.forEach((seg) => {\n    const segHeight = y(seg.y0) - y(seg.y1);\n    if (segHeight < 34 || bar.barWidth < 50) return;\n    group\n      .append(\"text\")\n      .attr(\"x\", bar.barWidth / 2)\n      .attr(\"y\", (y(seg.y1) + y(seg.y0)) / 2)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"dominant-baseline\", \"central\")\n      .attr(\"fill\", textColorFor(fillColor[seg.key]))\n      .style(\"font-size\", \"15px\")\n      .style(\"font-weight\", \"600\")\n      .text(`${Math.round(seg.pct)}%`);\n  });\n});\n\n// Baseline under the bars.\ng.append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", ih)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\n// --- X-axis: category name + group size, centered under each variable-width bar\nbarGroups.each(function (bar) {\n  const group = d3.select(this);\n  const cx = bar.barWidth / 2;\n  group\n    .append(\"text\")\n    .attr(\"x\", cx)\n    .attr(\"y\", ih + 32)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"16px\")\n    .style(\"font-weight\", \"600\")\n    .text(bar.category);\n  group\n    .append(\"text\")\n    .attr(\"x\", cx)\n    .attr(\"y\", ih + 54)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .text(`n = ${bar.n}`);\n});\n\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 88)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .style(\"font-style\", \"italic\")\n  .text(\"Bar width is proportional to group size (n)\");\n\n// --- Legend (measured with getBBox for exact centering) ---------------------\nconst legend = svg.append(\"g\");\nconst legendItems = fillKeys.map((key) => {\n  const item = legend.append(\"g\");\n  item\n    .append(\"rect\")\n    .attr(\"width\", 22)\n    .attr(\"height\", 22)\n    .attr(\"rx\", 3)\n    .attr(\"fill\", fillColor[key]);\n  item\n    .append(\"text\")\n    .attr(\"x\", 32)\n    .attr(\"y\", 16)\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"15px\")\n    .text(key);\n  return { node: item, width: item.node().getBBox().width };\n});\nconst gap = 40;\nconst legendWidth = d3.sum(legendItems, (d) => d.width) + gap * (legendItems.length - 1);\nlet lx = (width - legendWidth) / 2;\nlegendItems.forEach((d) => {\n  d.node.attr(\"transform\", `translate(${lx},96)`);\n  lx += d.width + gap;\n});\n\n// --- Title -------------------------------------------------------------------\nconst titleText = \"Clinical Trial Outcome by Dosage · bar-spine · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.max(14, Math.round(22 * Math.min(1, 67 / titleText.length)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(titleText);\n"}