{"spec_id":"crossword-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// crossword-basic: Crossword Puzzle Grid\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Standard 15x15 grid with 180-degree rotational symmetry: only the \"primary\"\n// half is listed below, and each entry's rotational partner\n// (SIZE-1-row, SIZE-1-col) is added automatically. Row 0 is the top row.\nconst SIZE = 15;\nconst PRIMARY_BLOCKS = [\n  [0, 3], [0, 11],\n  [1, 7],\n  [2, 3], [2, 11],\n  [3, 0], [3, 7], [3, 14],\n  [4, 4], [4, 10],\n  [5, 2], [5, 7], [5, 12],\n  [6, 5], [6, 9],\n  [7, 0], [7, 3], [7, 11], [7, 14],\n];\nconst blocked = new Set();\nPRIMARY_BLOCKS.forEach(([r, c]) => {\n  blocked.add(`${r},${c}`);\n  blocked.add(`${SIZE - 1 - r},${SIZE - 1 - c}`);\n});\nconst isBlocked = (r, c) => blocked.has(`${r},${c}`);\n\n// Standard numbering: a cell gets a number if it starts an across entry\n// (leftmost column or left neighbor blocked, plus a run of 2+ cells) or a\n// down entry (topmost row or top neighbor blocked, plus a run of 2+ cells).\nconst numbering = new Map();\nlet clueCount = 1;\nfor (let r = 0; r < SIZE; r++) {\n  for (let c = 0; c < SIZE; c++) {\n    if (isBlocked(r, c)) continue;\n    const startsAcross = (c === 0 || isBlocked(r, c - 1)) && c < SIZE - 1 && !isBlocked(r, c + 1);\n    const startsDown = (r === 0 || isBlocked(r - 1, c)) && r < SIZE - 1 && !isBlocked(r + 1, c);\n    if (startsAcross || startsDown) {\n      numbering.set(`${r},${c}`, clueCount);\n      clueCount += 1;\n    }\n  }\n}\n\nconst cells = [];\nfor (let r = 0; r < SIZE; r++) {\n  for (let c = 0; c < SIZE; c++) {\n    cells.push({\n      x: c,\n      y: SIZE - 1 - r, // row 0 at the top of the chart\n      row: r,\n      col: c,\n      isBlocked: isBlocked(r, c),\n      number: numbering.get(`${r},${c}`) ?? null,\n    });\n  }\n}\nconst numberedCells = cells.filter((cell) => cell.number !== null);\n\n// Monochrome design (spec: \"optimized for printing\") — the board itself\n// renders as printed paper, so cell fills and grid lines stay fixed\n// regardless of viewer theme (a physical newspaper page doesn't invert for\n// dark mode). Only the title/chrome (below) adapts to theme. Values reuse\n// the canonical light-theme page-bg / ink tokens rather than inventing new\n// hexes.\nconst PAPER_ENTRY = \"#FAF8F1\";\nconst PAPER_BLOCK = \"#1A1A17\";\nconst PAPER_GRID = \"rgba(26, 26, 23, 0.18)\";\n\n// --- Square-aspect layout + grid-drawing plugin -----------------------------\n// The title block eats vertical space the axis area doesn't eat horizontally,\n// so the raw chart area isn't square. Shrink it to the largest centered\n// square before the scatter points are laid out, then draw uniform grid\n// lines between every cell, the clue numbers, and a crisp outer frame.\nconst crosswordGrid = {\n  id: \"crosswordGrid\",\n  afterLayout(chart) {\n    const { x, y } = chart.scales;\n    if (!x || !y) return;\n    const width = x.right - x.left;\n    const height = y.bottom - y.top;\n    const side = Math.min(width, height);\n    x.left += (width - side) / 2;\n    x.right = x.left + side;\n    y.top += (height - side) / 2;\n    y.bottom = y.top + side;\n    chart.chartArea.left = x.left;\n    chart.chartArea.right = x.right;\n    chart.chartArea.top = y.top;\n    chart.chartArea.bottom = y.bottom;\n    // LinearScale caches _startPixel/_length in configure() during layout —\n    // it must re-run for the shrunk box to affect pixel conversion.\n    x.configure();\n    y.configure();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea: area, scales } = chart;\n    const { x, y } = scales;\n    if (!x || !y) return;\n\n    // Cell fills — drawn here directly (not left to Chart.js's own point\n    // renderer) so every element in this plugin — fills, grid lines, and\n    // numbers — shares one pixel mapping computed in the same pass. Letting\n    // Chart.js draw the points separately risked them reading a stale scale\n    // state from before the afterLayout square-crop above, which desynced\n    // the fills from the grid lines drawn here.\n    ctx.save();\n    cells.forEach((cell) => {\n      const left = x.getPixelForValue(cell.x - 0.5);\n      const right = x.getPixelForValue(cell.x + 0.5);\n      const top = y.getPixelForValue(cell.y + 0.5);\n      const bottom = y.getPixelForValue(cell.y - 0.5);\n      ctx.fillStyle = cell.isBlocked ? PAPER_BLOCK : PAPER_ENTRY;\n      ctx.fillRect(left, top, right - left, bottom - top);\n    });\n    ctx.restore();\n\n    // Uniform grid lines separating every cell (spec: \"Clean, uniform grid\n    // lines separating all cells\").\n    ctx.save();\n    ctx.strokeStyle = PAPER_GRID;\n    ctx.lineWidth = 1.5;\n    for (let i = 0; i <= SIZE; i++) {\n      const gx = x.getPixelForValue(i - 0.5);\n      ctx.beginPath();\n      ctx.moveTo(gx, area.top);\n      ctx.lineTo(gx, area.bottom);\n      ctx.stroke();\n\n      const gy = y.getPixelForValue(i - 0.5);\n      ctx.beginPath();\n      ctx.moveTo(area.left, gy);\n      ctx.lineTo(area.right, gy);\n      ctx.stroke();\n    }\n    ctx.restore();\n\n    // Clue numbers, top-left corner of each starting cell.\n    const cellSize = x.getPixelForValue(0.5) - x.getPixelForValue(-0.5);\n    ctx.save();\n    ctx.fillStyle = PAPER_BLOCK;\n    ctx.font = `600 ${Math.round(cellSize * 0.24)}px sans-serif`;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"top\";\n    const pad = cellSize * 0.08;\n    numberedCells.forEach(({ x: col, y: dataY, number }) => {\n      const cellLeft = x.getPixelForValue(col - 0.5);\n      const cellTop = y.getPixelForValue(dataY + 0.5);\n      ctx.fillText(String(number), cellLeft + pad, cellTop + pad);\n    });\n    ctx.restore();\n\n    // Outer frame so the board reads as one solid block.\n    ctx.save();\n    ctx.strokeStyle = PAPER_BLOCK;\n    ctx.lineWidth = 3;\n    ctx.strokeRect(area.left, area.top, area.right - area.left, area.bottom - area.top);\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  plugins: [crosswordGrid],\n  data: {\n    datasets: [\n      {\n        data: cells,\n        showLine: false,\n        pointStyle: \"rect\",\n        // Invisible — the crosswordGrid plugin draws the actual cell fills\n        // (see afterDatasetsDraw) so fills and grid lines share one pixel\n        // mapping. These points exist only to give the tooltip a hit target.\n        backgroundColor: \"transparent\",\n        pointBorderWidth: 0,\n        pointRadius: (ctx) => {\n          const { x, y } = ctx.chart.scales;\n          if (!x || !y) return 8;\n          const pxPerCol = Math.abs(x.getPixelForValue(1) - x.getPixelForValue(0));\n          const pxPerRow = Math.abs(y.getPixelForValue(1) - y.getPixelForValue(0));\n          return Math.min(pxPerCol, pxPerRow) / 2;\n        },\n        pointHoverBackgroundColor: \"transparent\",\n        pointHoverBorderColor: t.palette[0],\n        pointHoverBorderWidth: 2,\n      },\n    ],\n  },\n  options: {\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: \"crossword-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 26, weight: \"500\" },\n        padding: { bottom: 20 },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          title: (items) => `Row ${items[0].raw.row + 1}, Col ${items[0].raw.col + 1}`,\n          label: (item) => {\n            if (item.raw.isBlocked) return \"Blocked cell\";\n            return item.raw.number ? `Entry cell · clue ${item.raw.number}` : \"Entry cell\";\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: -0.5,\n        max: SIZE - 0.5,\n        ticks: { display: false },\n        grid: { display: false },\n        border: { display: false },\n      },\n      y: {\n        type: \"linear\",\n        min: -0.5,\n        max: SIZE - 0.5,\n        ticks: { display: false },\n        grid: { display: false },\n        border: { display: false },\n      },\n    },\n  },\n});\n"}