/* ============================================================ CoinRing — the signature 3D elliptical orbit gallery. Panels are positioned by real orbit math each frame (rAF). Depth (sin of angle) drives scale / opacity / blur / z / tilt. Motion: slow auto-rotate + mouse-X steer + Y tilt + drag momentum. Renders panels imperatively (no per-frame React reconciliation). Exposes window.CoinRing ============================================================ */ (function () { 'use strict'; var React = window.React; // Responsive ring geometry function geom(w) { if (w < 480) return { N: 46, rx: 236, ry: 104, pw: 26, ph: 74, zAmp: 96, persp: 900, ry3d: 20 }; if (w < 768) return { N: 58, rx: 330, ry: 122, pw: 29, ph: 86, zAmp: 118, persp: 1000, ry3d: 22 }; if (w < 1024) return { N: 72, rx: 500, ry: 150, pw: 35, ph: 104, zAmp: 130, persp: 1150, ry3d: 24 }; if (w < 1440) return { N: 88, rx: 646, ry: 178, pw: 39, ph: 116, zAmp: 142, persp: 1300, ry3d: 24 }; return { N: 100, rx: 742, ry: 196, pw: 41, ph: 122, zAmp: 150, persp: 1400, ry3d: 25 }; } var TAU = Math.PI * 2; function shortDelta(from, to) { var d = (to - from) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; } function CoinRing(props) { var coins = props.coins; var stageRef = React.useRef(null); var ring3dRef = React.useRef(null); var stateRef = React.useRef({ offset: 0, velocity: 0.0, steer: 0, tiltX: 0, pointerNX: 0, pointerNY: 0, pointerActive: false, dragging: false, lastX: 0, dragVel: 0, hoverIdx: -1, lockIdx: -1, panels: [], base: [], g: geom(1200), raf: 0, running: true }); // keep latest callbacks without rebuilding var cbRef = React.useRef(props); cbRef.current = props; React.useEffect(function () { var S = stateRef.current; var stage = stageRef.current; var ring = ring3dRef.current; var AUTO = 0.0016; function build() { var w = stage.clientWidth; S.g = geom(w); stage.style.perspective = S.g.persp + 'px'; // clear ring.innerHTML = ''; S.panels = []; S.base = []; var N = S.g.N; var frag = document.createDocumentFragment(); for (var i = 0; i < N; i++) { var coin = coins[i % coins.length]; var up = (coin.price_change_percentage_24h || 0) >= 0; var el = document.createElement('div'); el.className = 'ring-panel'; el.style.width = S.g.pw + 'px'; el.style.height = S.g.ph + 'px'; el.style.borderColor = up ? 'rgba(22,199,132,0.55)' : 'rgba(234,57,67,0.55)'; el.setAttribute('role', 'button'); el.setAttribute('aria-label', coin.name + ' ' + (up ? 'up' : 'down')); el.dataset.i = i; var img = document.createElement('img'); img.className = 'rp-logo'; img.loading = 'lazy'; img.alt = ''; img.src = coin.image; (function (c, imEl) { imEl.onerror = function () { imEl.style.display = 'none'; var f = document.createElement('div'); f.className = 'rp-fallback'; f.textContent = c.symbol.slice(0, 1); f.style.background = c.color || '#6D5EF6'; imEl.parentNode.insertBefore(f, imEl); }; })(coin, img); el.appendChild(img); var sym = document.createElement('span'); sym.className = 'rp-sym'; sym.textContent = coin.symbol; el.appendChild(sym); var bar = document.createElement('span'); bar.className = 'rp-bar'; bar.style.background = up ? 'var(--color-up)' : 'var(--color-down)'; el.appendChild(bar); (function (idx, c) { el.addEventListener('mouseenter', function () { if (S.dragging) return; S.hoverIdx = idx; if (cbRef.current.onHover) cbRef.current.onHover(c); }); el.addEventListener('mouseleave', function () { if (S.hoverIdx === idx) { S.hoverIdx = -1; if (cbRef.current.onHover) cbRef.current.onHover(null); } }); el.addEventListener('click', function (ev) { ev.stopPropagation(); S.lockIdx = idx; if (cbRef.current.onLock) cbRef.current.onLock(c, idx); }); })(i, coin); frag.appendChild(el); S.panels.push(el); S.base.push((i / N) * TAU); } ring.appendChild(frag); } function frame() { if (!S.running) return; var g = S.g; if (S.lockIdx >= 0) { var target = (Math.PI / 2) - S.base[S.lockIdx]; S.offset += shortDelta(S.offset, target) * 0.12; S.velocity = 0; } else if (!S.dragging) { S.offset += S.velocity + AUTO; S.velocity *= 0.94; } // steer + tilt easing var steerTarget = (S.pointerActive && !S.dragging && S.lockIdx < 0) ? S.pointerNX * 0.42 : 0; S.steer += (steerTarget - S.steer) * 0.05; var tiltTarget = S.pointerActive ? S.pointerNY * 6.5 : 0; S.tiltX += (tiltTarget - S.tiltX) * 0.06; ring.style.transform = 'rotateX(' + (-S.tiltX) + 'deg)'; var ro = S.offset + S.steer; var panels = S.panels, base = S.base, n = panels.length; for (var i = 0; i < n; i++) { var a = base[i] + ro; var c = Math.cos(a), s = Math.sin(a); var t = (s + 1) / 2; // 0 back .. 1 front var x = c * g.rx; var y = s * g.ry; var z = s * g.zAmp; var scale = 0.46 + t * 0.66; var ry = -c * g.ry3d; var bright = 0.60 + t * 0.42; var blur = (1 - t) * 1.5; var op = 0.24 + t * 0.76; if (i === S.hoverIdx) { scale *= 1.14; bright = Math.max(bright, 1.08); blur = 0; op = 1; } var el = panels[i]; el.style.transform = 'translate(-50%,-50%) translate3d(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px,' + z.toFixed(1) + 'px) scale(' + scale.toFixed(3) + ') rotateY(' + ry.toFixed(1) + 'deg)'; el.style.opacity = op.toFixed(2); el.style.filter = (blur > 0.06 ? 'blur(' + blur.toFixed(2) + 'px) ' : '') + 'brightness(' + bright.toFixed(2) + ')'; el.style.zIndex = String(Math.round(t * 200)); } S.raf = requestAnimationFrame(frame); } // ---- Pointer handlers ---- function onMove(e) { var r = stage.getBoundingClientRect(); var px = (e.clientX - r.left) / r.width; var py = (e.clientY - r.top) / r.height; S.pointerNX = (px - 0.5) * 2; S.pointerNY = (py - 0.5) * 2; S.pointerActive = true; if (S.dragging) { var dx = e.clientX - S.lastX; S.offset += dx * 0.006; S.dragVel = dx * 0.006; S.lastX = e.clientX; if (S.lockIdx >= 0) { S.lockIdx = -1; if (cbRef.current.onUnlock) cbRef.current.onUnlock(); } } } function onDown(e) { S.dragging = true; S.lastX = e.clientX; S.dragVel = 0; stage.classList.add('grabbing'); if (cbRef.current.onDragStart) cbRef.current.onDragStart(); } function onUp() { if (!S.dragging) return; S.dragging = false; stage.classList.remove('grabbing'); // hand momentum to velocity (clamped) var v = S.dragVel; S.velocity = Math.max(-0.05, Math.min(0.05, v)); } function onLeave() { S.pointerActive = false; onUp(); } build(); stage.addEventListener('pointermove', onMove); stage.addEventListener('pointerdown', onDown); window.addEventListener('pointerup', onUp); stage.addEventListener('pointerleave', onLeave); var ro = new ResizeObserver(function () { var w = stage.clientWidth; var ng = geom(w); if (ng.N !== S.g.N) build(); else { S.g = ng; stage.style.perspective = ng.persp + 'px'; } }); ro.observe(stage); S.running = true; S.raf = requestAnimationFrame(frame); return function () { S.running = false; cancelAnimationFrame(S.raf); ro.disconnect(); stage.removeEventListener('pointermove', onMove); stage.removeEventListener('pointerdown', onDown); window.removeEventListener('pointerup', onUp); stage.removeEventListener('pointerleave', onLeave); }; }, [coins]); // expose unlock to parent (e.g. clicking backdrop) React.useImperativeHandle(props.handle, function () { return { unlock: function () { stateRef.current.lockIdx = -1; }, spinTo: function (idx) { stateRef.current.lockIdx = idx; } }; }); return React.createElement('div', { className: 'ring-stage', ref: stageRef, onClick: function () { /* click empty area unlocks */ var S = stateRef.current; if (S.hoverIdx < 0 && S.lockIdx >= 0) { S.lockIdx = -1; if (props.onUnlock) props.onUnlock(); } } }, React.createElement('div', { className: 'ring-3d', ref: ring3dRef })); } window.CoinRing = CoinRing; })();