{"spec_id":"maze-circular","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// maze-circular: Circular Maze Puzzle\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: circular-maze generation (in-memory, deterministic) -------------\n// rings=7, difficulty=\"medium\" (sector density below), seed=20260902.\n// Ring 0 is a single central cell; sector count doubles every other ring so\n// corridor width stays roughly constant as the circumference grows.\nconst RINGS = 7;\nconst sectorsPerRing = [1];\nlet sectorCount = 6;\nfor (let r = 1; r < RINGS; r++) {\n  sectorsPerRing.push(sectorCount);\n  if (r % 2 === 0) sectorCount *= 2;\n}\n\n// Tiny fixed-seed LCG — the browser has no seeded RNG, and Math.random() is\n// not reproducible across runs.\nconst makeRng = (seed) => {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n};\nconst rng = makeRng(20260902);\n\n// Neighbor edges of cell (r, s), each tagged with the wall key it would carve.\n// A radial edge is keyed by the lower-index sector on its clockwise side; an\n// edge to the ring below is keyed by the outer cell's own inner wall — both\n// keys are reached identically from either direction, so no edge is double-\n// counted.\nconst neighborsOf = (r, s) => {\n  const n = sectorsPerRing[r];\n  const list = [];\n  if (n > 1) {\n    const next = (s + 1) % n;\n    const prev = (s - 1 + n) % n;\n    list.push({ cell: [r, next], key: `radial:${r}:${s}` });\n    list.push({ cell: [r, prev], key: `radial:${r}:${prev}` });\n  }\n  if (r > 0) {\n    const ratioIn = n / sectorsPerRing[r - 1];\n    list.push({ cell: [r - 1, Math.floor(s / ratioIn)], key: `inner:${r}:${s}` });\n  }\n  if (r < RINGS - 1) {\n    const ratioOut = sectorsPerRing[r + 1] / n;\n    for (let k = 0; k < ratioOut; k++) {\n      const outerSector = s * ratioOut + k;\n      list.push({ cell: [r + 1, outerSector], key: `inner:${r + 1}:${outerSector}` });\n    }\n  }\n  return list;\n};\n\n// Iterative recursive-backtracker: carves a spanning tree over every cell, so\n// exactly one path connects any two cells — the puzzle has exactly one\n// solution, as required.\nconst visited = sectorsPerRing.map((n) => new Array(n).fill(false));\nvisited[0][0] = true;\nconst removedWalls = new Set();\nconst stack = [[0, 0]];\nwhile (stack.length > 0) {\n  const [r, s] = stack[stack.length - 1];\n  const options = neighborsOf(r, s).filter(({ cell }) => !visited[cell[0]][cell[1]]);\n  if (options.length === 0) {\n    stack.pop();\n    continue;\n  }\n  const pick = options[Math.floor(rng() * options.length)];\n  removedWalls.add(pick.key);\n  visited[pick.cell[0]][pick.cell[1]] = true;\n  stack.push(pick.cell);\n}\n\nconst entrySector = Math.floor(rng() * sectorsPerRing[RINGS - 1]);\n\n// Ring boundary radii, in abstract units (boundary[r] is the inner edge of\n// ring r; boundary[RINGS] is the outer edge of the whole maze).\nconst boundary = Array.from({ length: RINGS + 1 }, (_, r) => r);\nconst maxRadius = boundary[RINGS];\n\n// Solution path (entry -> center) through the spanning tree, reconstructed\n// from removedWalls via BFS. Used to power a hover-reveal interaction below.\nconst cellKey = (r, s) => `${r}:${s}`;\nconst adjacency = new Map();\nfor (let r = 0; r < RINGS; r++) {\n  for (let s = 0; s < sectorsPerRing[r]; s++) {\n    const key = cellKey(r, s);\n    const carved = neighborsOf(r, s)\n      .filter(({ key: edgeKey }) => removedWalls.has(edgeKey))\n      .map(({ cell }) => cell);\n    adjacency.set(key, carved);\n  }\n}\nconst startCell = [RINGS - 1, entrySector];\nconst cameFrom = new Map([[cellKey(...startCell), null]]);\nconst queue = [startCell];\nwhile (queue.length > 0) {\n  const cur = queue.shift();\n  if (cur[0] === 0 && cur[1] === 0) break;\n  for (const next of adjacency.get(cellKey(...cur))) {\n    const nk = cellKey(...next);\n    if (!cameFrom.has(nk)) {\n      cameFrom.set(nk, cur);\n      queue.push(next);\n    }\n  }\n}\nconst solutionPath = [[0, 0]];\nwhile (cellKey(...solutionPath[solutionPath.length - 1]) !== cellKey(...startCell)) {\n  solutionPath.push(cameFrom.get(cellKey(...solutionPath[solutionPath.length - 1])));\n}\nsolutionPath.reverse();\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Maze rendering plugin ---------------------------------------------------\n// Chart.js has no native maze/board chart type; a \"scatter\" chart with an\n// empty dataset supplies the canvas lifecycle, theming and title plugin,\n// while this plugin draws the rings, radial walls and start/goal markers\n// directly against chart.chartArea — Chart.js's own plugin API, no external\n// chartjs-chart-* package involved.\n// Hover state for the solution-path reveal — driven by Chart.js's own\n// afterEvent hook (native event lifecycle, not a DOM listener bolted on).\nlet pathHovered = false;\n\nconst circularMazePlugin = {\n  id: \"circularMaze\",\n  afterEvent(chart, args) {\n    const { type } = args.event;\n    const next = type === \"mouseout\" ? false : type === \"mousemove\" || type === \"mouseenter\" ? true : pathHovered;\n    if (next !== pathHovered) {\n      pathHovered = next;\n      args.changed = true;\n    }\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const minDim = Math.min(chartArea.width, chartArea.height);\n    const cx = (chartArea.left + chartArea.right) / 2;\n    const cy = (chartArea.top + chartArea.bottom) / 2;\n    const outerRadiusPx = minDim * 0.42;\n    const pxScale = outerRadiusPx / maxRadius;\n    const angleAt = (frac) => -Math.PI / 2 + frac * 2 * Math.PI;\n    const pointAt = (radiusUnits, angleRad) => ({\n      x: cx + radiusUnits * pxScale * Math.cos(angleRad),\n      y: cy + radiusUnits * pxScale * Math.sin(angleRad),\n    });\n\n    ctx.save();\n\n    // Maze disc — distinguishes corridor space from the page background, with\n    // a soft drop shadow and a hairline border for a finished, print-ready edge.\n    ctx.save();\n    ctx.shadowColor = t.grid;\n    ctx.shadowBlur = minDim * 0.02;\n    ctx.shadowOffsetY = minDim * 0.006;\n    ctx.beginPath();\n    ctx.arc(cx, cy, outerRadiusPx, 0, Math.PI * 2);\n    ctx.fillStyle = t.elevatedBg;\n    ctx.fill();\n    ctx.restore();\n    ctx.beginPath();\n    ctx.arc(cx, cy, outerRadiusPx, 0, Math.PI * 2);\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = Math.max(1.5, minDim * 0.0015);\n    ctx.stroke();\n\n    const wallWidth = Math.max(2.5, minDim * 0.0032);\n    ctx.strokeStyle = t.ink;\n    ctx.lineWidth = wallWidth;\n    ctx.lineCap = \"round\";\n    ctx.lineJoin = \"round\";\n\n    // Ring-boundary arcs — one per sector, skipped where a passage was carved.\n    for (let r = 1; r < RINGS; r++) {\n      const n = sectorsPerRing[r];\n      const radiusPx = boundary[r] * pxScale;\n      for (let s = 0; s < n; s++) {\n        if (removedWalls.has(`inner:${r}:${s}`)) continue;\n        ctx.beginPath();\n        ctx.arc(cx, cy, radiusPx, angleAt(s / n), angleAt((s + 1) / n));\n        ctx.stroke();\n      }\n    }\n\n    // Outer perimeter — full circle except the entry gap.\n    {\n      const outerRing = RINGS - 1;\n      const n = sectorsPerRing[outerRing];\n      const radiusPx = boundary[RINGS] * pxScale;\n      for (let s = 0; s < n; s++) {\n        if (s === entrySector) continue;\n        ctx.beginPath();\n        ctx.arc(cx, cy, radiusPx, angleAt(s / n), angleAt((s + 1) / n));\n        ctx.stroke();\n      }\n    }\n\n    // Radial walls — straight segments between adjacent sectors in a ring.\n    for (let r = 1; r < RINGS; r++) {\n      const n = sectorsPerRing[r];\n      if (n <= 1) continue;\n      const rInnerPx = boundary[r] * pxScale;\n      const rOuterPx = boundary[r + 1] * pxScale;\n      for (let s = 0; s < n; s++) {\n        if (removedWalls.has(`radial:${r}:${s}`)) continue;\n        const theta = angleAt((s + 1) / n);\n        ctx.beginPath();\n        ctx.moveTo(cx + rInnerPx * Math.cos(theta), cy + rInnerPx * Math.sin(theta));\n        ctx.lineTo(cx + rOuterPx * Math.cos(theta), cy + rOuterPx * Math.sin(theta));\n        ctx.stroke();\n      }\n    }\n\n    // Entry marker — brand green, points inward through the perimeter gap.\n    const entryN = sectorsPerRing[RINGS - 1];\n    const entryAngle = angleAt((entrySector + 0.5) / entryN);\n    const entryOuter = pointAt(maxRadius * 1.16, entryAngle);\n    const entryInner = pointAt(maxRadius * 0.97, entryAngle);\n    ctx.strokeStyle = t.palette[0];\n    ctx.lineWidth = wallWidth * 1.4;\n    ctx.beginPath();\n    ctx.moveTo(entryOuter.x, entryOuter.y);\n    ctx.lineTo(entryInner.x, entryInner.y);\n    ctx.stroke();\n\n    const labelSize = Math.round(minDim * 0.022);\n    ctx.fillStyle = t.palette[0];\n    ctx.font = `600 ${labelSize}px sans-serif`;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = Math.sin(entryAngle) > 0 ? \"top\" : \"bottom\";\n    const entryLabel = pointAt(maxRadius * 1.24, entryAngle);\n    ctx.fillText(\"START\", entryLabel.x, entryLabel.y);\n\n    // Goal marker — brand blue, filled disc at the true center.\n    const goalRadiusPx = pxScale * 0.5;\n    ctx.beginPath();\n    ctx.arc(cx, cy, goalRadiusPx, 0, Math.PI * 2);\n    ctx.fillStyle = t.palette[2];\n    ctx.fill();\n    ctx.strokeStyle = t.pageBg;\n    ctx.lineWidth = 2;\n    ctx.stroke();\n    ctx.fillStyle = t.palette[2];\n    ctx.font = `600 ${labelSize}px sans-serif`;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"top\";\n    ctx.fillText(\"GOAL\", cx, cy + goalRadiusPx + labelSize * 0.5);\n\n    // Hover-reveal solution path — genuine chart.js interactivity (driven by\n    // the afterEvent hook above), only ever visible in the interactive HTML\n    // view; the static PNG screenshot never carries a hover state.\n    if (pathHovered) {\n      ctx.beginPath();\n      solutionPath.forEach(([r, s], i) => {\n        const n = sectorsPerRing[r];\n        const radiusUnits = (boundary[r] + boundary[r + 1]) / 2;\n        const p = pointAt(radiusUnits, angleAt((s + 0.5) / n));\n        if (i === 0) ctx.moveTo(p.x, p.y);\n        else ctx.lineTo(p.x, p.y);\n      });\n      ctx.strokeStyle = t.palette[0];\n      ctx.globalAlpha = 0.55;\n      ctx.lineWidth = wallWidth * 2.2;\n      ctx.lineJoin = \"round\";\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n    }\n\n    // Hint chrome — tells viewers of the interactive HTML view that hovering\n    // reveals the solution; harmless static text in the static PNG.\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = `400 ${Math.round(labelSize * 0.75)}px sans-serif`;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"top\";\n    ctx.fillText(\"Hover to trace the solution path\", chartArea.left, chartArea.top);\n\n    ctx.restore();\n    window.__anyplotReady = true;\n  },\n};\n\n// --- Title (scale fontsize to the rendered length, see plot-generator.md) --\nconst title = \"maze-circular · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / title.length));\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [{ data: [] }] },\n  plugins: [circularMazePlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 24 },\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: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { display: false, min: -1, max: 1 },\n      y: { display: false, min: -1, max: 1 },\n    },\n  },\n});\n"}