{"spec_id":"bar-diverging-likert","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// bar-diverging-likert: Likert Scale Diverging Bar Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-08-24\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Theme-adaptive chrome not covered by ANYPLOT_TOKENS -------------------\nconst INK_MUTED = t.theme === \"light\" ? \"#6B6A63\" : \"#A8A79F\";\n\n// --- Color helpers -----------------------------------------------------------\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction mix(hexA, hexB, amount) {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  return a.map((c, i) => Math.round(c + (b[i] - c) * amount));\n}\n\nfunction relativeLuminance(rgb) {\n  const [r, g, b] = rgb.map((c) => {\n    const s = c / 255;\n    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);\n  });\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\nfunction textColorFor(rgb) {\n  // near-white / near-black ink tokens instead of pure #FFFFFF / #000000\n  return relativeLuminance(rgb) > 0.4 ? \"#1A1A17\" : \"#FAF8F1\";\n}\n\n// A red-to-blue diverging scale, sampled at 5 Likert steps. Strongly-* segments\n// use the fixed Imprint `div` anchors (t.div[0] red, t.div[2] blue); the mild\n// segments are a small red/blue blend of those same fixed anchors, so all four\n// stay theme-independent. Only \"Neutral\" uses the theme-adaptive muted-ink\n// anchor — that role is documented as adaptive-by-design in the style guide.\nconst RED = t.div[0];\nconst BLUE = t.div[2];\nconst COLORS = {\n  strongly_disagree: hexToRgb(RED),\n  disagree: mix(RED, BLUE, 0.15),\n  neutral: hexToRgb(INK_MUTED),\n  agree: mix(BLUE, RED, 0.15),\n  strongly_agree: hexToRgb(BLUE),\n};\nconst rgbStr = (rgb) => `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Employee engagement survey, 8 items on a 5-point Likert scale (% of respondents).\nconst rawRows = [\n  {\n    question: \"I feel valued for my contributions\",\n    strongly_disagree: 3,\n    disagree: 7,\n    neutral: 10,\n    agree: 45,\n    strongly_agree: 35,\n  },\n  {\n    question: \"My manager gives me useful feedback\",\n    strongly_disagree: 4,\n    disagree: 10,\n    neutral: 16,\n    agree: 42,\n    strongly_agree: 28,\n  },\n  {\n    question: \"I have the tools I need to do my job well\",\n    strongly_disagree: 5,\n    disagree: 12,\n    neutral: 13,\n    agree: 40,\n    strongly_agree: 30,\n  },\n  {\n    question: \"Team collaboration works well\",\n    strongly_disagree: 6,\n    disagree: 14,\n    neutral: 15,\n    agree: 40,\n    strongly_agree: 25,\n  },\n  {\n    question: \"Leadership communicates a clear vision\",\n    strongly_disagree: 8,\n    disagree: 18,\n    neutral: 20,\n    agree: 36,\n    strongly_agree: 18,\n  },\n  {\n    question: \"I see a clear path for career growth here\",\n    strongly_disagree: 10,\n    disagree: 22,\n    neutral: 23,\n    agree: 30,\n    strongly_agree: 15,\n  },\n  {\n    question: \"I feel comfortable raising concerns\",\n    strongly_disagree: 12,\n    disagree: 25,\n    neutral: 18,\n    agree: 30,\n    strongly_agree: 15,\n  },\n  {\n    question: \"Work is fairly distributed across the team\",\n    strongly_disagree: 15,\n    disagree: 28,\n    neutral: 20,\n    agree: 25,\n    strongly_agree: 12,\n  },\n];\n\n// Sort by net agreement: (agree + strongly agree) - (disagree + strongly disagree)\nconst rows = [...rawRows].sort((a, b) => {\n  const netA = a.agree + a.strongly_agree - (a.disagree + a.strongly_disagree);\n  const netB = b.agree + b.strongly_agree - (b.disagree + b.strongly_disagree);\n  return netB - netA;\n});\n\nfunction wrapLabel(text, maxLen) {\n  const words = text.split(\" \");\n  const lines = [];\n  let line = \"\";\n  words.forEach((word) => {\n    const candidate = line ? `${line} ${word}` : word;\n    if (candidate.length > maxLen && line) {\n      lines.push(line);\n      line = word;\n    } else {\n      line = candidate;\n    }\n  });\n  if (line) lines.push(line);\n  return lines;\n}\n\nconst labels = rows.map((r) => wrapLabel(r.question, 28));\nconst LABEL_MIN_PCT = 6; // skip drawing labels on segments too small to hold text\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Diverging stack: neutral split in half around the center, then\n// disagree/agree, then strongly-disagree/strongly-agree stacking outward. ----\nconst datasets = [\n  {\n    label: \"Neutral\",\n    data: rows.map((r) => -r.neutral / 2),\n    backgroundColor: rgbStr(COLORS.neutral),\n    stack: \"likert\",\n    labelValues: rows.map((r) => r.neutral),\n    labelColor: textColorFor(COLORS.neutral),\n    showLabels: false,\n  },\n  {\n    label: \"Neutral\",\n    data: rows.map((r) => r.neutral / 2),\n    backgroundColor: rgbStr(COLORS.neutral),\n    stack: \"likert\",\n    labelValues: rows.map((r) => r.neutral),\n    labelColor: textColorFor(COLORS.neutral),\n    showLabels: true,\n    labelAtZero: true,\n  },\n  {\n    label: \"Disagree\",\n    data: rows.map((r) => -r.disagree),\n    backgroundColor: rgbStr(COLORS.disagree),\n    stack: \"likert\",\n    labelValues: rows.map((r) => r.disagree),\n    labelColor: textColorFor(COLORS.disagree),\n    showLabels: true,\n  },\n  {\n    label: \"Agree\",\n    data: rows.map((r) => r.agree),\n    backgroundColor: rgbStr(COLORS.agree),\n    stack: \"likert\",\n    labelValues: rows.map((r) => r.agree),\n    labelColor: textColorFor(COLORS.agree),\n    showLabels: true,\n  },\n  {\n    label: \"Strongly Disagree\",\n    data: rows.map((r) => -r.strongly_disagree),\n    backgroundColor: rgbStr(COLORS.strongly_disagree),\n    stack: \"likert\",\n    labelValues: rows.map((r) => r.strongly_disagree),\n    labelColor: textColorFor(COLORS.strongly_disagree),\n    showLabels: true,\n  },\n  {\n    label: \"Strongly Agree\",\n    data: rows.map((r) => r.strongly_agree),\n    backgroundColor: rgbStr(COLORS.strongly_agree),\n    stack: \"likert\",\n    labelValues: rows.map((r) => r.strongly_agree),\n    labelColor: textColorFor(COLORS.strongly_agree),\n    showLabels: true,\n  },\n];\n\n// Symmetric axis extent, rounded up to a friendly step\nconst extent = Math.max(\n  ...rows.map((r) => r.strongly_disagree + r.disagree + r.neutral / 2),\n  ...rows.map((r) => r.strongly_agree + r.agree + r.neutral / 2),\n);\nconst axisMax = Math.ceil(extent / 10) * 10;\n\n// --- Custom plugin: draw the percentage inside each segment ----------------\nconst segmentLabels = {\n  id: \"segmentLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.font = \"600 15px Arial, sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    chart.data.datasets.forEach((dataset, datasetIndex) => {\n      if (!dataset.showLabels) return;\n      const meta = chart.getDatasetMeta(datasetIndex);\n      meta.data.forEach((bar, index) => {\n        const pct = dataset.labelValues[index];\n        if (pct < LABEL_MIN_PCT) return;\n        const xCenter = dataset.labelAtZero\n          ? chart.scales.x.getPixelForValue(0)\n          : (bar.x + bar.base) / 2;\n        ctx.fillStyle = dataset.labelColor;\n        ctx.fillText(`${pct}%`, xCenter, bar.y);\n      });\n    });\n    ctx.restore();\n  },\n};\n\n// --- Title (fontsize scales with title length — see Title Format rule) -----\nconst TITLE = \"bar-diverging-likert · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.round(\n  22 * (TITLE.length > 67 ? 67 / TITLE.length : 1),\n);\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bar\",\n  data: { labels, datasets },\n  plugins: [segmentLabels],\n  options: {\n    indexAxis: \"y\",\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 24, bottom: 8, left: 8 } },\n    plugins: {\n      title: {\n        display: true,\n        text: TITLE,\n        color: t.ink,\n        font: { size: titleFontSize, weight: \"500\" },\n        padding: { bottom: 20 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: t.ink,\n          font: { size: 15 },\n          boxWidth: 18,\n          padding: 18,\n          generateLabels() {\n            return [\n              {\n                text: \"Strongly Disagree\",\n                fillStyle: rgbStr(COLORS.strongly_disagree),\n                strokeStyle: rgbStr(COLORS.strongly_disagree),\n              },\n              {\n                text: \"Disagree\",\n                fillStyle: rgbStr(COLORS.disagree),\n                strokeStyle: rgbStr(COLORS.disagree),\n              },\n              {\n                text: \"Neutral\",\n                fillStyle: rgbStr(COLORS.neutral),\n                strokeStyle: rgbStr(COLORS.neutral),\n              },\n              {\n                text: \"Agree\",\n                fillStyle: rgbStr(COLORS.agree),\n                strokeStyle: rgbStr(COLORS.agree),\n              },\n              {\n                text: \"Strongly Agree\",\n                fillStyle: rgbStr(COLORS.strongly_agree),\n                strokeStyle: rgbStr(COLORS.strongly_agree),\n              },\n            ];\n          },\n        },\n      },\n      tooltip: {\n        backgroundColor: t.elevatedBg,\n        titleColor: t.ink,\n        bodyColor: t.ink,\n        borderColor: t.grid,\n        borderWidth: 1,\n        callbacks: {\n          label: (ctx) =>\n            `${ctx.dataset.label}: ${ctx.dataset.labelValues[ctx.dataIndex]}%`,\n        },\n      },\n    },\n    scales: {\n      x: {\n        stacked: true,\n        min: -axisMax,\n        max: axisMax,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => `${Math.abs(value)}%`,\n        },\n        grid: {\n          color: (ctx) => (ctx.tick.value === 0 ? t.ink : t.grid),\n          lineWidth: (ctx) => (ctx.tick.value === 0 ? 2 : 1),\n        },\n        title: {\n          display: true,\n          text: \"Share of Respondents\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        stacked: true,\n        categoryPercentage: 0.75,\n        barPercentage: 0.9,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        border: { display: false },\n      },\n    },\n  },\n});\n"}