{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"360-viewer","type":"registry:block","title":"360° viewer","description":"\"PDPs spin with 123\" — the multi-angle inspection jewelry buyers demand. Production feeds it a photo turntable or our GLBs.","author":"Motion Menu","categories":["role-gallery","boutique","commerce","pointer"],"dependencies":[],"registryDependencies":["@motionmenu/motion-menu-runtime"],"meta":{"number":"123","slug":"360-viewer","framework":"react","docs":"https://motion-menu-two.vercel.app/p/360-viewer.md"},"files":[{"path":"motion-menu/360-viewer.html","type":"registry:file","target":"motion-menu/360-viewer.html","content":"<article class=\"card\">\n        <header><span class=\"num\">123</span><h3>360° viewer</h3><div class=\"tags\"><i>jewelry</i><i>drag</i></div><a class=\"format-action\" data-format-link data-pattern-slug=\"360-viewer\" href=\"/r/360-viewer.json?framework=react\" title=\"Open the React registry output\">React ↗</a></header>\n        <div class=\"demo\"><canvas class=\"spin-canvas\" id=\"spin360\"></canvas><p class=\"demo-hint\">drag to spin · flick for momentum</p></div>\n        <p class=\"order\">Say: <b>\"PDPs spin with 123\"</b> — the multi-angle inspection jewelry buyers demand. Production feeds it a photo turntable or our GLBs.</p>\n      </article>"},{"path":"motion-menu/360-viewer.css","type":"registry:file","target":"motion-menu/360-viewer.css","content":".card {\n    border: 1px solid var(--line); border-radius: 14px; background: var(--card);\n    overflow: hidden; display: flex; flex-direction: column;\n  }\n\n.card > header { display: flex; align-items: baseline; gap: .8rem; padding: 1.1rem 1.2rem .9rem; border-bottom: 1px solid var(--line); }\n\n.card .num { font-family: var(--mono); color: var(--acc); font-size: 1.05rem; }\n\n.card h3 { font-size: 1.02rem; font-weight: 600; letter-spacing: -0.01em; flex: 1; }\n\n.tags { display: flex; gap: .35rem; }\n\n.tags i {\n    font: 400 .62rem/1 var(--mono); font-style: normal; color: var(--dim);\n    border: 1px solid var(--line); border-radius: 99px; padding: .28em .6em; white-space: nowrap;\n  }\n\n.demo {\n    min-height: 250px; position: relative; display: flex; align-items: center; justify-content: center;\n    background: var(--bg2); overflow: hidden; flex: 1;\n  }\n\n.order { padding: .85rem 1.2rem; font-family: var(--mono); font-size: .72rem; color: var(--dim); border-top: 1px solid var(--line); }\n\n.order b { color: var(--txt); font-weight: 500; }\n\n.demo .btn.corner { position: absolute; top: 12px; right: 12px; padding: .55em 1em; font-size: .68rem; z-index: 5; }\n\n.demo-hint { position: absolute; bottom: 10px; left: 0; right: 0; text-align: center; font: 400 .65rem var(--mono); color: var(--dim); pointer-events: none; z-index: 4; }\n\n.ai-card .num { color: var(--acc2) !important; }\n\n/* ---------- Sections S/T/U/V · full domain build ---------- */\n  .spin-canvas { position: absolute; inset: 0; width: 100%; height: 100%; cursor: grab; touch-action: pan-y; }\n\n.spin-canvas:active { cursor: grabbing; }\n\n.demo:has(.hascheck input:checked) .hasbtn { border-color: var(--acc); color: var(--acc); pointer-events: auto; opacity: 1; }"},{"path":"components/motion-menu/360-viewer.tsx","type":"registry:component","content":"// @ts-nocheck — generated wrapper; inlined pattern JS is imperative DOM code, not authored TS.\n\"use client\";\n// 360° viewer — Motion Menu\n// Calls the Motion Menu shared runtime (webglDemo / canvas2dDemo / art helpers).\n// Install `motion-menu-runtime` and mount it once at the app root, or the effect\n// below throws ReferenceError on first paint.\nimport { useEffect, useRef } from \"react\";\n\nconst MARKUP = \"<article class=\\\"card\\\">\\n        <header><span class=\\\"num\\\">123</span><h3>360\\u00b0 viewer</h3><div class=\\\"tags\\\"><i>jewelry</i><i>drag</i></div><a class=\\\"format-action\\\" data-format-link data-pattern-slug=\\\"360-viewer\\\" href=\\\"/r/360-viewer.json?framework=react\\\" title=\\\"Open the React registry output\\\">React \\u2197</a></header>\\n        <div class=\\\"demo\\\"><canvas class=\\\"spin-canvas\\\" id=\\\"spin360\\\"></canvas><p class=\\\"demo-hint\\\">drag to spin \\u00b7 flick for momentum</p></div>\\n        <p class=\\\"order\\\">Say: <b>\\\"PDPs spin with 123\\\"</b> \\u2014 the multi-angle inspection jewelry buyers demand. Production feeds it a photo turntable or our GLBs.</p>\\n      </article>\";\n\nexport function 360Viewer() {\n  const hostRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) return;\n    const $ = (s: string) => host.querySelector(s) as HTMLElement | null;\n    let disposed = false;\n    const cleanup: Array<() => void> = [];\n\n    (async () => {\n      if (disposed) return;\n      try {\n        /* ---- 123 · 360° viewer ---- */\n        canvas2dDemo('spin360', (d) => {\n          const s = { a: 0.6, v: 0.4, dragging: false, lx: 0, lt: 0 };\n          d.canvas.addEventListener('pointerdown', (e) => { s.dragging = true; s.v = 0; s.lx = e.clientX; s.lt = performance.now(); d.canvas.setPointerCapture(e.pointerId); });\n          d.canvas.addEventListener('pointermove', (e) => {\n            if (!s.dragging) return;\n            const now = performance.now();\n            const dx = e.clientX - s.lx;\n            s.a += dx * 0.012;\n            s.v = dx / Math.max(now - s.lt, 1) * 12;\n            s.lx = e.clientX; s.lt = now;\n          });\n          const end = () => (s.dragging = false);\n          d.canvas.addEventListener('pointerup', end);\n          d.canvas.addEventListener('pointercancel', end);\n          return (t, delta) => {\n            if (!s.dragging) { s.a += s.v * delta; s.v *= Math.pow(0.25, delta); }\n            const { ctx, w, h } = d;\n            ctx.clearRect(0, 0, w, h);\n            const cx = w / 2, cy = h / 2 + 10;\n            const a = s.a;\n            const squash = Math.abs(Math.sin(a)) * 0.8 + 0.2;\n            // band\n            ctx.save();\n            ctx.translate(cx, cy);\n            ctx.scale(1, squash);\n            ctx.lineWidth = 13;\n            const grad = ctx.createLinearGradient(-60, -60, 60, 60);\n            grad.addColorStop(0, '#f0f0f6'); grad.addColorStop(0.5, '#9a9aa8'); grad.addColorStop(1, '#e8e8ee');\n            ctx.strokeStyle = grad;\n            ctx.beginPath(); ctx.arc(0, 0, 58, 0, Math.PI * 2); ctx.stroke();\n            // moving glint on band\n            ctx.strokeStyle = 'rgba(255,255,255,.9)'; ctx.lineWidth = 4;\n            ctx.beginPath(); ctx.arc(0, 0, 58, a * 1.4, a * 1.4 + 0.5); ctx.stroke();\n            ctx.restore();\n            // gem riding the band edge\n            const gy = cy - 58 * squash;\n            ctx.save();\n            ctx.translate(cx, gy);\n            ctx.fillStyle = '#b7ff2e';\n            ctx.beginPath();\n            ctx.moveTo(0, -14); ctx.lineTo(11, 0); ctx.lineTo(0, 10); ctx.lineTo(-11, 0); ctx.closePath();\n            ctx.fill();\n            ctx.fillStyle = 'rgba(255,255,255,.85)';\n            const sp = Math.abs(Math.sin(a * 2.3));\n            ctx.beginPath(); ctx.arc(4 - sp * 8, -5, 1.6 + sp * 1.6, 0, Math.PI * 2); ctx.fill();\n            ctx.restore();\n            ctx.fillStyle = 'rgba(232,232,238,0.4)';\n            ctx.font = '10px monospace'; ctx.textAlign = 'center';\n            ctx.fillText(`${String(Math.round(((a % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) / Math.PI / 2 * 360)).padStart(3, '0')}°`, cx, h - 12);\n          };\n        });\n      } catch (err: any) {\n        console.warn(\"[motion-menu] 360-viewer failed to start:\", err);\n      }\n    })();\n\n    return () => {\n      disposed = true;\n      cleanup.forEach((fn) => { try { fn(); } catch (_) {} });\n    };\n  }, []);\n\n  return <div ref={hostRef} dangerouslySetInnerHTML={{ __html: MARKUP }} />;\n}\n\nexport default 360Viewer;\n"}]}