{"spec_id":"waffle-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// waffle-basic: Basic Waffle Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-09\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Marketing budget allocation across spending categories, values sum to 100.\nconst categories = [\n  { label: \"Digital Advertising\", value: 32 },\n  { label: \"Content Marketing\", value: 24 },\n  { label: \"Events & Sponsorships\", value: 18 },\n  { label: \"Public Relations\", value: 14 },\n  { label: \"Tools & Software\", value: 12 },\n];\n\nconst color = d3.scaleOrdinal()\n  .domain(categories.map((d) => d.label))\n  .range(t.palette);\n\n// Expand into 100 grid cells (one square = 1%), in category order.\nconst cellCategories = categories.flatMap((d) => Array(d.value).fill(d));\n\nconst gridCols = 10;\nconst gridRows = 10;\nconst cells = cellCategories.map((d, i) => ({\n  category: d,\n  row: Math.floor(i / gridCols),\n  col: i % gridCols,\n}));\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// Soft drop shadow for the grid group — a small depth cue beyond flat fills.\nconst shadow = svg.append(\"defs\").append(\"filter\")\n  .attr(\"id\", \"waffleShadow\").attr(\"x\", \"-20%\").attr(\"y\", \"-20%\").attr(\"width\", \"140%\").attr(\"height\", \"140%\");\nshadow.append(\"feDropShadow\")\n  .attr(\"dx\", 0).attr(\"dy\", 2).attr(\"stdDeviation\", 3)\n  .attr(\"flood-color\", \"#000000\").attr(\"flood-opacity\", 0.16);\n\n// --- Title ----------------------------------------------------------------\nsvg.append(\"text\").attr(\"x\", width / 2).attr(\"y\", 64).attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"waffle-basic · javascript · d3 · anyplot.ai\");\n\n// --- Waffle grid --------------------------------------------------------------\nconst marginTop = 140;\nconst legendHeight = 220;\nconst sideMargin = 100;\nconst availableWidth = width - 2 * sideMargin;\nconst availableHeight = height - marginTop - legendHeight;\nconst gridSize = Math.min(availableWidth, availableHeight);\nconst cellGap = 4;\nconst cellSize = (gridSize - (gridCols - 1) * cellGap) / gridCols;\nconst gridX = (width - gridSize) / 2;\nconst gridY = marginTop;\nconst leadingCategory = categories[0].label;\n\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${gridX},${gridY})`).attr(\"filter\", \"url(#waffleShadow)\");\n\ng.selectAll(\"rect\").data(cells).join(\"rect\")\n  .attr(\"x\", (d) => d.col * (cellSize + cellGap))\n  .attr(\"y\", (d) => d.row * (cellSize + cellGap))\n  .attr(\"width\", cellSize)\n  .attr(\"height\", cellSize)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", (d) => color(d.category.label))\n  .attr(\"stroke\", (d) => (d.category.label === leadingCategory ? t.ink : \"none\"))\n  .attr(\"stroke-opacity\", 0.3)\n  .attr(\"stroke-width\", 1.5);\n\n// Decade-band guides: a faint dashed rule every 2 rows (20 cells = 20% of the\n// whole), giving the eye a fifths-of-the-total rhythm to count against.\nconst bandRowBoundaries = [2, 4, 6, 8];\ng.selectAll(\".band-guide\").data(bandRowBoundaries).join(\"line\").attr(\"class\", \"band-guide\")\n  .attr(\"x1\", -6).attr(\"x2\", gridSize + 6)\n  .attr(\"y1\", (r) => r * (cellSize + cellGap) - cellGap / 2)\n  .attr(\"y2\", (r) => r * (cellSize + cellGap) - cellGap / 2)\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1).attr(\"stroke-dasharray\", \"2,3\").attr(\"stroke-opacity\", 0.6);\n\n// --- Legend ---------------------------------------------------------------\nconst legendY = marginTop + gridSize + 64;\nconst swatchSize = 25;\nconst itemGap = 34;\nconst legendRowGap = 46;\nconst legendMaxWidth = availableWidth;\nconst legendFontSize = \"19px\";\n\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(0,${legendY})`);\n\n// Measure each item's text width first so items can be centered as a group.\nconst measure = legend.append(\"text\").style(\"font-size\", legendFontSize).attr(\"opacity\", 0);\nconst itemWidths = categories.map((d) => {\n  measure.text(`${d.label} (${d.value}%)`);\n  return swatchSize + 10 + measure.node().getComputedTextLength();\n});\nmeasure.remove();\n\n// Greedy-wrap legend items into centered rows that fit within the grid's\n// width — avoids clipping when larger text pushes a single row too wide.\nconst legendRows = [];\nlet row = [];\nlet rowWidth = 0;\ncategories.forEach((d, i) => {\n  const added = row.length === 0 ? itemWidths[i] : itemWidths[i] + itemGap;\n  if (row.length > 0 && rowWidth + added > legendMaxWidth) {\n    legendRows.push(row);\n    row = [];\n    rowWidth = 0;\n  }\n  row.push(i);\n  rowWidth += row.length === 1 ? itemWidths[i] : itemWidths[i] + itemGap;\n});\nif (row.length) legendRows.push(row);\n\nlegendRows.forEach((indices, r) => {\n  const rowTotalWidth = indices.reduce((sum, i) => sum + itemWidths[i], 0) + itemGap * (indices.length - 1);\n  let cursorX = (width - rowTotalWidth) / 2;\n  const rowY = r * (swatchSize + legendRowGap);\n\n  indices.forEach((i) => {\n    const d = categories[i];\n    const isLeading = d.label === leadingCategory;\n    const item = legend.append(\"g\").attr(\"transform\", `translate(${cursorX},${rowY})`);\n    item.append(\"rect\")\n      .attr(\"width\", swatchSize).attr(\"height\", swatchSize)\n      .attr(\"rx\", 3)\n      .attr(\"fill\", color(d.label))\n      .attr(\"stroke\", isLeading ? t.ink : \"none\")\n      .attr(\"stroke-opacity\", 0.4)\n      .attr(\"stroke-width\", 2);\n    item.append(\"text\")\n      .attr(\"x\", swatchSize + 10).attr(\"y\", swatchSize / 2)\n      .attr(\"dominant-baseline\", \"middle\")\n      .attr(\"fill\", isLeading ? t.ink : t.inkSoft).style(\"font-size\", legendFontSize)\n      .style(\"font-weight\", isLeading ? \"700\" : \"400\")\n      .text(`${d.label} (${d.value}%)`);\n    cursorX += itemWidths[i] + itemGap;\n  });\n});\n"}