{"spec_id":"mosaic-categorical","library":"d3","language":"javascript","code":"// anyplot.ai\n// mosaic-categorical: Mosaic Plot for Categorical Association Analysis\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 130, right: 60, bottom: 66, left: 95 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Web-traffic acquisition: visits by source, cross-tabulated against outcome.\nconst sources = [\"Organic Search\", \"Paid Ads\", \"Social Media\", \"Referral\"];\nconst outcomes = [\"Converted\", \"Bounced\"];\nconst counts = {\n  \"Organic Search\": { Converted: 180, Bounced: 620 },\n  \"Paid Ads\": { Converted: 150, Bounced: 350 },\n  \"Social Media\": { Converted: 40, Bounced: 460 },\n  Referral: { Converted: 90, Bounced: 110 },\n};\n\nconst sourceTotals = sources.map((s) => counts[s].Converted + counts[s].Bounced);\nconst grandTotal = sourceTotals.reduce((a, b) => a + b, 0);\nconst color = d3.scaleOrdinal().domain(outcomes).range([t.palette[0], t.palette[4]]);\nconst LABEL_ON_FILL = \"#FFFDF6\"; // fixed light label ink for text on saturated data fills, both themes\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// --- Column layout (widths = marginal share of category_1) -------------------\nconst gapX = 10;\nconst usableW = iw - gapX * (sources.length - 1);\nlet xCursor = 0;\nconst columns = sources.map((source, i) => {\n  const colWidth = usableW * (sourceTotals[i] / grandTotal);\n  const col = { source, x: xCursor, width: colWidth, total: sourceTotals[i] };\n  xCursor += colWidth + gapX;\n  return col;\n});\n\n// --- Reference y-axis (conditional proportion, shared across columns) --------\nconst y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);\nconst yAxis = g.append(\"g\").call(\n  d3.axisLeft(y)\n    .tickValues([0, 0.25, 0.5, 0.75, 1])\n    .tickFormat(d3.format(\".0%\"))\n    .tickSize(-iw)\n);\nyAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nyAxis.selectAll(\"line\").attr(\"stroke\", t.grid);\nyAxis.select(\".domain\").remove();\n\n// --- Y-axis title (identifies the conditional-proportion encoding) -----------\ng.append(\"text\")\n  .attr(\"transform\", `translate(${-64},${ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Conversion Rate\");\n\n// --- X-axis title (identifies the column-width categorical variable) ---------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 42)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Traffic Source\");\n\n// --- Rectangles (heights = conditional proportion within column) -------------\nconst gapY = 6;\nconst usableH = ih - gapY;\n\nfor (const col of columns) {\n  const convertedFrac = counts[col.source].Converted / col.total;\n  const bouncedFrac = 1 - convertedFrac;\n  const convertedH = usableH * convertedFrac;\n  const bouncedH = usableH * bouncedFrac;\n\n  const cell = g.append(\"g\").attr(\"transform\", `translate(${col.x},0)`);\n\n  // Converted — anchored at the bottom\n  cell\n    .append(\"rect\")\n    .attr(\"x\", 0)\n    .attr(\"y\", ih - convertedH)\n    .attr(\"width\", col.width)\n    .attr(\"height\", convertedH)\n    .attr(\"fill\", color(\"Converted\"));\n\n  // Bounced — stacked above, separated by a gap\n  cell\n    .append(\"rect\")\n    .attr(\"x\", 0)\n    .attr(\"y\", 0)\n    .attr(\"width\", col.width)\n    .attr(\"height\", bouncedH - gapY / 2)\n    .attr(\"fill\", color(\"Bounced\"));\n\n  // Percentage labels — only where the segment is tall enough to hold text\n  if (convertedH > 32) {\n    cell\n      .append(\"text\")\n      .attr(\"x\", col.width / 2)\n      .attr(\"y\", ih - convertedH / 2)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"dominant-baseline\", \"middle\")\n      .attr(\"fill\", LABEL_ON_FILL)\n      .style(\"font-size\", \"15px\")\n      .style(\"font-weight\", \"600\")\n      .text(d3.format(\".0%\")(convertedFrac));\n  }\n  if (bouncedH - gapY / 2 > 32) {\n    cell\n      .append(\"text\")\n      .attr(\"x\", col.width / 2)\n      .attr(\"y\", (bouncedH - gapY / 2) / 2)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"dominant-baseline\", \"middle\")\n      .attr(\"fill\", LABEL_ON_FILL)\n      .style(\"font-size\", \"15px\")\n      .style(\"font-weight\", \"600\")\n      .text(d3.format(\".0%\")(bouncedFrac));\n  }\n\n  // Column header — source name + sample size, wrapped over two lines\n  const header = g\n    .append(\"text\")\n    .attr(\"x\", col.x + col.width / 2)\n    .attr(\"y\", -38)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"16px\")\n    .style(\"font-weight\", \"600\");\n  header.append(\"tspan\").attr(\"x\", col.x + col.width / 2).attr(\"dy\", 0).text(col.source);\n  header\n    .append(\"tspan\")\n    .attr(\"x\", col.x + col.width / 2)\n    .attr(\"dy\", \"1.3em\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .style(\"font-weight\", \"400\")\n    .text(`n = ${col.total.toLocaleString()}`);\n}\n\n// --- Legend --------------------------------------------------------------------\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${width - margin.right - 220},44)`);\nlegend\n  .append(\"text\")\n  .attr(\"x\", 0)\n  .attr(\"y\", -8)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Outcome\");\noutcomes.forEach((outcome, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(${i * 115},0)`);\n  row.append(\"rect\").attr(\"width\", 16).attr(\"height\", 16).attr(\"rx\", 3).attr(\"fill\", color(outcome));\n  row\n    .append(\"text\")\n    .attr(\"x\", 22)\n    .attr(\"y\", 13)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(outcome);\n});\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(\"mosaic-categorical · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 72)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Traffic source vs. conversion outcome — column width = share of visits, height = conversion rate\");\n"}