{"spec_id":"maze-circular","library":"echarts","language":"javascript","code":"// anyplot.ai\n// maze-circular: Circular Maze Puzzle\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n//\n// ECharts has no native maze primitive, so the maze is built by hand: a graph\n// of one center hub plus ring x sector cells, carved into a spanning tree via\n// randomized DFS (recursive backtracker). A spanning tree connects every cell\n// through exactly one path, which is what guarantees the maze has exactly one\n// solution from the outer entry to the center goal -- no loops, no shortcuts.\n// The walls are drawn as polylines (arcs approximated by sampled points) and\n// straight radial segments inside a single hidden-axis custom series so the\n// geometry maps 1:1 onto the mount's actual pixel scale via api.coord().\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Maze parameters (one concrete scenario: 7 rings, medium difficulty) ---\nconst RINGS = 7;\nconst SECTORS = 12;\nconst DIFFICULTY = \"medium\";\nconst SEED = 42;\nconst HUB_RADIUS = 0.12; // fraction of the outer radius reserved for the goal hub\nconst OUTER_RADIUS = 1.0;\nconst RING_WIDTH = (OUTER_RADIUS - HUB_RADIUS) / RINGS;\nconst ANGLE_STEP = (2 * Math.PI) / SECTORS;\nconst ANGLE_OFFSET = Math.PI / 2; // sector 0 starts at 12 o'clock\n\n// --- Deterministic RNG (mulberry32) -----------------------------------------\nlet seedState = SEED >>> 0;\nfunction rand() {\n  seedState = (seedState + 0x6d2b79f5) >>> 0;\n  let z = seedState;\n  z = Math.imul(z ^ (z >>> 15), z | 1);\n  z ^= z + Math.imul(z ^ (z >>> 7), z | 61);\n  return ((z ^ (z >>> 14)) >>> 0) / 4294967296;\n}\n\n// --- Graph: \"C\" (center hub) plus one node per (ring, sector) cell ----------\nfunction cellId(ring, sector) {\n  return `${ring}_${sector}`;\n}\nfunction ringOf(node) {\n  return node === \"C\" ? -1 : Number(node.split(\"_\")[0]);\n}\nfunction neighborsOf(node) {\n  if (node === \"C\") {\n    const out = [];\n    for (let s = 0; s < SECTORS; s++) out.push(cellId(0, s));\n    return out;\n  }\n  const [ringStr, sectorStr] = node.split(\"_\");\n  const ring = Number(ringStr);\n  const sector = Number(sectorStr);\n  const out = [\n    cellId(ring, (sector + 1) % SECTORS),\n    cellId(ring, (sector - 1 + SECTORS) % SECTORS),\n  ];\n  out.push(ring === 0 ? \"C\" : cellId(ring - 1, sector));\n  if (ring < RINGS - 1) out.push(cellId(ring + 1, sector));\n  return out;\n}\nfunction edgeKey(a, b) {\n  return a < b ? `${a}|${b}` : `${b}|${a}`;\n}\n\n// DIFFICULTY biases *which* spanning tree the carve picks -- it never adds\n// loops, so the exactly-one-solution guarantee always holds. \"easy\" favors\n// moves toward the center (short, direct solution path); \"hard\" favors\n// lateral/outward moves (long, winding solution path with more dead ends).\nconst RING_MOVE_WEIGHT = {\n  easy: { inward: 4, lateral: 1, outward: 0.5 },\n  medium: { inward: 1, lateral: 1, outward: 1 },\n  hard: { inward: 0.4, lateral: 1, outward: 2 },\n}[DIFFICULTY];\n\nfunction pickWeighted(current, options) {\n  const currentRing = ringOf(current);\n  const weights = options.map((node) => {\n    const nodeRing = ringOf(node);\n    if (nodeRing < currentRing) return RING_MOVE_WEIGHT.inward;\n    if (nodeRing > currentRing) return RING_MOVE_WEIGHT.outward;\n    return RING_MOVE_WEIGHT.lateral;\n  });\n  let roll = rand() * weights.reduce((sum, w) => sum + w, 0);\n  for (let i = 0; i < options.length; i++) {\n    roll -= weights[i];\n    if (roll <= 0) return options[i];\n  }\n  return options[options.length - 1];\n}\n\n// Randomized DFS (recursive backtracker) -> spanning tree = exactly one path\n// between any two cells, which is what guarantees a single maze solution.\nconst visited = new Set([\"C\"]);\nconst passages = new Set();\nconst stack = [\"C\"];\nwhile (stack.length > 0) {\n  const current = stack[stack.length - 1];\n  const options = neighborsOf(current).filter((n) => !visited.has(n));\n  if (options.length === 0) {\n    stack.pop();\n    continue;\n  }\n  const next = pickWeighted(current, options);\n  passages.add(edgeKey(current, next));\n  visited.add(next);\n  stack.push(next);\n}\nconst entrySector = Math.floor(rand() * SECTORS);\n\n// --- Geometry helpers --------------------------------------------------------\nfunction angleOf(sector) {\n  return ANGLE_OFFSET + sector * ANGLE_STEP;\n}\nfunction ringRadius(ringBoundary) {\n  return HUB_RADIUS + ringBoundary * RING_WIDTH;\n}\nfunction arcPoints(radius, angleStart, angleEnd, steps) {\n  const pts = [];\n  for (let k = 0; k <= steps; k++) {\n    const angle = angleStart + ((angleEnd - angleStart) * k) / steps;\n    pts.push([radius * Math.cos(angle), radius * Math.sin(angle)]);\n  }\n  return pts;\n}\n\n// --- Wall segments (data-space points, mapped to pixels via api.coord) -----\nconst arcWalls = []; // circumferential walls, one polyline per drawn segment\nconst radialWalls = []; // radial walls, one [p1, p2] pair per drawn segment\n\nfor (let ringBoundary = 0; ringBoundary <= RINGS; ringBoundary++) {\n  const radius = ringRadius(ringBoundary);\n  for (let sector = 0; sector < SECTORS; sector++) {\n    let present;\n    if (ringBoundary === 0) {\n      present = !passages.has(edgeKey(\"C\", cellId(0, sector)));\n    } else if (ringBoundary === RINGS) {\n      present = sector !== entrySector; // outer boundary, minus the entry gap\n    } else {\n      present = !passages.has(\n        edgeKey(cellId(ringBoundary - 1, sector), cellId(ringBoundary, sector)),\n      );\n    }\n    if (present) {\n      arcWalls.push(arcPoints(radius, angleOf(sector), angleOf(sector + 1), 6));\n    }\n  }\n}\nfor (let ring = 0; ring < RINGS; ring++) {\n  const innerRadius = ringRadius(ring);\n  const outerRadius = ringRadius(ring + 1);\n  for (let sector = 0; sector < SECTORS; sector++) {\n    const prevSector = (sector - 1 + SECTORS) % SECTORS;\n    const present = !passages.has(edgeKey(cellId(ring, prevSector), cellId(ring, sector)));\n    if (present) {\n      const angle = angleOf(sector);\n      radialWalls.push([\n        [innerRadius * Math.cos(angle), innerRadius * Math.sin(angle)],\n        [outerRadius * Math.cos(angle), outerRadius * Math.sin(angle)],\n      ]);\n    }\n  }\n}\n\n// --- Entry marker: an inward-pointing arrow at the outer gap ---------------\nconst entryAngle = angleOf(entrySector) + ANGLE_STEP / 2;\nconst entryCos = Math.cos(entryAngle);\nconst entrySin = Math.sin(entryAngle);\nconst entryArrowOuter = [(OUTER_RADIUS + 0.09) * entryCos, (OUTER_RADIUS + 0.09) * entrySin];\nconst entryArrowInner = [(OUTER_RADIUS + 0.015) * entryCos, (OUTER_RADIUS + 0.015) * entrySin];\nconst entryLabelAt = [(OUTER_RADIUS + 0.14) * entryCos, (OUTER_RADIUS + 0.14) * entrySin];\n\n// --- Init & render ------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nconst AX = { type: \"value\", min: -1.3, max: 1.3, show: false };\n\nconst option = {\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"maze-circular · javascript · echarts · anyplot.ai\",\n    subtext: `${RINGS} rings · ${SECTORS} sectors · ${DIFFICULTY} difficulty · seed ${SEED}`,\n    left: \"center\",\n    top: 26,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: \"bold\" },\n    subtextStyle: { color: t.inkSoft, fontSize: 15 },\n  },\n  // top+bottom sums to the same 300px as left+right so the plotting box\n  // stays square (no elliptical distortion); the split is uneven to shift\n  // the box down slightly and balance the whitespace above vs below it.\n  grid: { left: 150, right: 150, top: 170, bottom: 130 },\n  xAxis: AX,\n  yAxis: AX,\n  series: [\n    {\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      silent: true,\n      data: [0],\n      renderItem: (params, api) => {\n        const children = [];\n\n        for (const pts of arcWalls) {\n          children.push({\n            type: \"polyline\",\n            shape: { points: pts.map((p) => api.coord(p)) },\n            style: { stroke: t.ink, lineWidth: 5, fill: \"none\", lineCap: \"round\" },\n          });\n        }\n        for (const [p1, p2] of radialWalls) {\n          const a = api.coord(p1);\n          const b = api.coord(p2);\n          children.push({\n            type: \"line\",\n            shape: { x1: a[0], y1: a[1], x2: b[0], y2: b[1] },\n            style: { stroke: t.ink, lineWidth: 5, lineCap: \"round\" },\n          });\n        }\n\n        // Goal hub at the center\n        const centerPx = api.coord([0, 0]);\n        const hubRadiusPx = api.coord([HUB_RADIUS, 0])[0] - centerPx[0];\n        children.push({\n          type: \"circle\",\n          shape: { cx: centerPx[0], cy: centerPx[1], r: hubRadiusPx },\n          style: { fill: t.palette[0], stroke: t.pageBg, lineWidth: 3 },\n        });\n        children.push({\n          type: \"text\",\n          style: {\n            text: \"GOAL\",\n            x: centerPx[0],\n            y: centerPx[1],\n            fill: t.pageBg,\n            fontSize: 15,\n            fontWeight: \"bold\",\n            align: \"center\",\n            verticalAlign: \"middle\",\n          },\n        });\n\n        // Entry arrow (points inward through the gap) + label\n        const outerPx = api.coord(entryArrowOuter);\n        const innerPx = api.coord(entryArrowInner);\n        const dx = innerPx[0] - outerPx[0];\n        const dy = innerPx[1] - outerPx[1];\n        const len = Math.hypot(dx, dy) || 1;\n        const perpX = (-dy / len) * 10;\n        const perpY = (dx / len) * 10;\n        children.push({\n          type: \"polygon\",\n          shape: {\n            points: [\n              [innerPx[0], innerPx[1]],\n              [outerPx[0] + perpX, outerPx[1] + perpY],\n              [outerPx[0] - perpX, outerPx[1] - perpY],\n            ],\n          },\n          style: { fill: t.palette[0] },\n        });\n        const labelPx = api.coord(entryLabelAt);\n        children.push({\n          type: \"text\",\n          style: {\n            text: \"START\",\n            x: labelPx[0],\n            y: labelPx[1],\n            fill: t.palette[0],\n            fontSize: 15,\n            fontWeight: \"bold\",\n            align: \"center\",\n            verticalAlign: \"middle\",\n          },\n        });\n\n        return { type: \"group\", silent: true, children };\n      },\n    },\n  ],\n};\n\nchart.setOption(option);\n"}