{"spec_id":"mosaic-categorical","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// mosaic-categorical: Mosaic Plot for Categorical Association Analysis\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Survey of employees: department (category_1, column width) vs. work mode\n// (category_2, stacked row height) — a classic contingency-table scenario.\nconst departments = [\"Engineering\", \"Sales\", \"Marketing\", \"Support\"];\nconst workModes = [\"Remote\", \"Hybrid\", \"Onsite\"];\nconst counts = [\n  [180, 90, 30], // Engineering\n  [40, 60, 100], // Sales\n  [70, 50, 30], // Marketing\n  [20, 40, 90], // Support\n];\n\nconst rowTotals = counts.map((row) => row.reduce((sum, n) => sum + n, 0));\nconst grandTotal = rowTotals.reduce((sum, n) => sum + n, 0);\n\n// Column edges as fractions of [0, 1] — width encodes the marginal share of\n// each department among all employees.\nconst colEdges = [0];\nrowTotals.forEach((total) =>\n  colEdges.push(colEdges[colEdges.length - 1] + total / grandTotal),\n);\n\n// Stacked segment heights as fractions of [0, 1] within a column — height\n// encodes the conditional share of each work mode within that department.\nconst segmentFractions = workModes.map((_, modeIndex) =>\n  counts.map((row, deptIndex) => row[modeIndex] / rowTotals[deptIndex]),\n);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// Relative luminance (WCAG-style) so a tile's value label always contrasts\n// against that tile's own fill color, independent of the active theme.\nfunction readableTextColor(hex) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n  return luminance > 0.5 ? \"#1A1A17\" : \"#FAF8F1\";\n}\n\n// Hover state for the canvas hit-tested tooltip (populated by the mousemove\n// listener below, consumed by mosaicPlugin's afterDraw).\nlet hoverTile = null;\nlet tileRects = [];\n\nfunction pickTile(offsetX, offsetY, chartArea) {\n  const { left, top, width, height } = chartArea;\n  const xFrac = (offsetX - left) / width;\n  const yFrac = (offsetY - top) / height;\n  if (xFrac < 0 || xFrac > 1 || yFrac < 0 || yFrac > 1) return null;\n  const colIndex = colEdges.findIndex(\n    (edge, i) =>\n      i < colEdges.length - 1 && xFrac >= edge && xFrac < colEdges[i + 1],\n  );\n  if (colIndex === -1) return null;\n  let cursor = 0;\n  for (let modeIndex = 0; modeIndex < workModes.length; modeIndex++) {\n    const frac = segmentFractions[modeIndex][colIndex];\n    if (yFrac >= cursor && yFrac < cursor + frac)\n      return { colIndex, modeIndex };\n    cursor += frac;\n  }\n  return null;\n}\n\nfunction drawTooltip(ctx, anchorX, anchorY, lines) {\n  ctx.font = \"bold 13px sans-serif\";\n  const headWidth = ctx.measureText(lines[0]).width;\n  ctx.font = \"12px sans-serif\";\n  const bodyWidth = Math.max(\n    ...lines.slice(1).map((line) => ctx.measureText(line).width),\n  );\n  const boxWidth = Math.max(headWidth, bodyWidth) + 24;\n  const lineHeight = 18;\n  const boxHeight = lineHeight * lines.length + 16;\n  const boxX = Math.min(anchorX + 14, canvas.clientWidth - boxWidth - 8);\n  const boxY = Math.max(anchorY - boxHeight - 14, 8);\n\n  ctx.save();\n  ctx.shadowColor = \"rgba(0, 0, 0, 0.25)\";\n  ctx.shadowBlur = 10;\n  ctx.shadowOffsetY = 3;\n  ctx.fillStyle = t.elevatedBg;\n  const radius = 6;\n  ctx.beginPath();\n  ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);\n  ctx.fill();\n  ctx.shadowColor = \"transparent\";\n  ctx.strokeStyle = t.grid;\n  ctx.lineWidth = 1;\n  ctx.stroke();\n\n  ctx.textAlign = \"left\";\n  ctx.textBaseline = \"top\";\n  ctx.fillStyle = t.ink;\n  ctx.font = \"bold 13px sans-serif\";\n  ctx.fillText(lines[0], boxX + 12, boxY + 8);\n  ctx.fillStyle = t.inkSoft;\n  ctx.font = \"12px sans-serif\";\n  lines.slice(1).forEach((line, i) => {\n    ctx.fillText(line, boxX + 12, boxY + 8 + lineHeight * (i + 1));\n  });\n  ctx.restore();\n}\n\n// --- Plugin: draw the mosaic tiles + department labels + hover tooltip -------\n// Chart.js has no native mosaic/variable-width-bar controller, so the tiles are\n// drawn directly onto the chart's own canvas from the finalized chartArea —\n// this uses only core Chart.js plugin hooks (no external plugin package).\nconst tileGap = 4;\nconst mosaicPlugin = {\n  id: \"mosaicTiles\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const { left, top, width, height, bottom } = chartArea;\n\n    tileRects = departments.map(() => []);\n    ctx.save();\n    departments.forEach((dept, colIndex) => {\n      const xStart = left + colEdges[colIndex] * width;\n      const xEnd = left + colEdges[colIndex + 1] * width;\n      const colWidth = xEnd - xStart;\n\n      let yCursor = top;\n      workModes.forEach((mode, modeIndex) => {\n        const segHeight = segmentFractions[modeIndex][colIndex] * height;\n        const x = xStart + tileGap / 2;\n        const y = yCursor + tileGap / 2;\n        const w = Math.max(0, colWidth - tileGap);\n        const h = Math.max(0, segHeight - tileGap);\n        tileRects[colIndex][modeIndex] = { x, y, w, h };\n\n        ctx.save();\n        ctx.shadowColor = \"rgba(0, 0, 0, 0.18)\";\n        ctx.shadowBlur = 5;\n        ctx.shadowOffsetY = 2;\n        ctx.fillStyle = t.palette[modeIndex];\n        ctx.fillRect(x, y, w, h);\n        ctx.restore();\n\n        // Value label on tiles large enough to hold text without crowding.\n        const sharePct = segmentFractions[modeIndex][colIndex] * 100;\n        if (w > 64 && h > 32) {\n          ctx.fillStyle = readableTextColor(t.palette[modeIndex]);\n          ctx.font = \"bold 14px sans-serif\";\n          ctx.textAlign = \"center\";\n          ctx.textBaseline = \"middle\";\n          ctx.fillText(`${Math.round(sharePct)}%`, x + w / 2, y + h / 2);\n        }\n        yCursor += segHeight;\n      });\n    });\n    ctx.restore();\n\n    // Department labels (category_1) directly below their column.\n    ctx.save();\n    ctx.fillStyle = t.ink;\n    ctx.font = \"bold 14px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"top\";\n    departments.forEach((dept, colIndex) => {\n      const xCenter =\n        left + ((colEdges[colIndex] + colEdges[colIndex + 1]) / 2) * width;\n      ctx.fillText(dept, xCenter, bottom + 12);\n    });\n    ctx.font = \"italic 12px Georgia, serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.fillText(\n      \"Department (column width ∝ headcount share)\",\n      left + width / 2,\n      bottom + 32,\n    );\n    ctx.restore();\n\n    // Hover tooltip — real canvas hit-testing driven by native mouse events\n    // (see the mousemove/mouseleave listeners below), not a static overlay.\n    if (hoverTile) {\n      const { colIndex, modeIndex } = hoverTile;\n      const rect = tileRects[colIndex][modeIndex];\n      const dept = departments[colIndex];\n      const mode = workModes[modeIndex];\n      const count = counts[colIndex][modeIndex];\n      const deptShare = segmentFractions[modeIndex][colIndex] * 100;\n      const totalShare = (count / grandTotal) * 100;\n      drawTooltip(ctx, rect.x + rect.w / 2, rect.y, [\n        `${dept} · ${mode}`,\n        `${count} employees`,\n        `${deptShare.toFixed(0)}% of ${dept}`,\n        `${totalShare.toFixed(1)}% of all employees`,\n      ]);\n    }\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\n// No dataset elements are rendered — the y scale supplies the percentage\n// ruler and reserves layout space, while mosaicPlugin paints the tiles.\nconst chart = new Chart(canvas, {\n  type: \"bar\",\n  data: { labels: departments, datasets: [] },\n  plugins: [mosaicPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 16, bottom: 46, left: 8 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"mosaic-categorical · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      subtitle: {\n        display: true,\n        text: \"Engineering skews remote, Support skews onsite — hover a tile for exact counts\",\n        color: t.inkSoft,\n        font: { size: 14, style: \"italic\" },\n        padding: { bottom: 8 },\n      },\n      legend: {\n        position: \"right\",\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          boxWidth: 20,\n          generateLabels: () =>\n            workModes.map((mode, i) => ({\n              text: mode,\n              fillStyle: t.palette[i],\n              strokeStyle: t.palette[i],\n              lineWidth: 0,\n            })),\n        },\n        title: {\n          display: true,\n          text: \"Work mode\",\n          color: t.ink,\n          font: { size: 14 },\n        },\n        onClick: () => {},\n      },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { display: false },\n      y: {\n        min: 0,\n        max: 1,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => `${Math.round(value * 100)}%`,\n        },\n        grid: { color: t.grid },\n        border: { display: false },\n        title: {\n          display: true,\n          text: \"Share within department\",\n          color: t.ink,\n          font: { size: 14 },\n        },\n      },\n    },\n  },\n});\n\n// --- Interactivity: canvas hit-tested hover tooltip --------------------------\n// Chart.js has no data points to hover (datasets: [] — see mosaicPlugin above),\n// so genuine tooltip interactivity is wired by hand: translate mouse position\n// into chart-area fractions, resolve the tile under the cursor, and redraw via\n// the plugin's afterDraw. Never fires during the static PNG screenshot, since\n// no synthetic mouse event is dispatched there — only in the interactive HTML.\ncanvas.addEventListener(\"mousemove\", (event) => {\n  const tile = pickTile(event.offsetX, event.offsetY, chart.chartArea);\n  const changed = JSON.stringify(tile) !== JSON.stringify(hoverTile);\n  if (changed) {\n    hoverTile = tile;\n    canvas.style.cursor = tile ? \"pointer\" : \"default\";\n    chart.draw();\n  }\n});\ncanvas.addEventListener(\"mouseleave\", () => {\n  if (hoverTile) {\n    hoverTile = null;\n    canvas.style.cursor = \"default\";\n    chart.draw();\n  }\n});\n"}