/** * folk-exchange-node — Canvas shape rendering a P2P exchange order board. * * Buy orbs (green, left) ↔ sell orbs (amber, right) with connecting arcs * for matched trades. Status badges on orbs. Polls /api/exchange/intents every 30s. */ import { FolkShape } from './folk-shape'; import { css, html } from './tags'; import { getModuleApiBase } from '../shared/url-helpers'; // ── Constants ── const BUY_COLOR = '#10b981'; const SELL_COLOR = '#f59e0b'; const LP_COLOR_LEFT = '#10b981'; const LP_COLOR_RIGHT = '#f59e0b'; const MATCH_ARC_COLOR = '#60a5fa'; const BG_COLOR = '#0f172a'; const TEXT_COLOR = '#e2e8f0'; const MUTED_COLOR = '#64748b'; const TOKEN_ICONS: Record = { cusdc: '💵', myco: '🌱', fusdc: '🎮', }; // ── Types ── interface OrderIntent { id: string; creatorName: string; side: 'buy' | 'sell'; tokenId: string; fiatCurrency: string; tokenAmountMin: number; tokenAmountMax: number; rateType: string; rateFixed?: number; rateMarketBps?: number; paymentMethods: string[]; isStandingOrder: boolean; status: string; } interface LpPosition { id: string; creatorName: string; tokenId: string; fiatCurrency: string; tokenRemaining: number; fiatRemaining: number; spreadBps: number; feesEarnedToken: number; feesEarnedFiat: number; tradesMatched: number; status: string; } interface LpOrb { position: LpPosition; x: number; y: number; radius: number; vx: number; vy: number; phase: number; opacity: number; hoverT: number; } interface Orb { intent: OrderIntent; x: number; y: number; radius: number; vx: number; vy: number; phase: number; opacity: number; hoverT: number; color: string; } // ── Shape ── export class FolkExchangeNode extends FolkShape { static override tagName = 'folk-exchange-node'; private _canvas: HTMLCanvasElement | null = null; private _ctx: CanvasRenderingContext2D | null = null; private _orbs: Orb[] = []; private _lpOrbs: LpOrb[] = []; private _hovered: Orb | null = null; private _hoveredLp: LpOrb | null = null; private _animFrame = 0; private _pollTimer = 0; private _spaceSlug = 'demo'; private _intents: OrderIntent[] = []; private _lpPositions: LpPosition[] = []; override connectedCallback() { super.connectedCallback(); this._spaceSlug = this.getAttribute('space') || this.getAttribute('spaceSlug') || 'demo'; this._setup(); this._fetchIntents(); this._pollTimer = window.setInterval(() => this._fetchIntents(), 30000); } override disconnectedCallback() { super.disconnectedCallback(); if (this._animFrame) cancelAnimationFrame(this._animFrame); if (this._pollTimer) clearInterval(this._pollTimer); } private _setup() { const shadow = this.shadowRoot!; const wrapper = document.createElement('div'); wrapper.style.cssText = 'width:100%;height:100%;position:relative;overflow:hidden;border-radius:12px;background:' + BG_COLOR; // Header const header = document.createElement('div'); header.style.cssText = 'position:absolute;top:0;left:0;right:0;padding:12px 16px;z-index:1;display:flex;justify-content:space-between;align-items:center'; header.innerHTML = ` 💱 rExchange 0 intents `; wrapper.appendChild(header); // Legend const legend = document.createElement('div'); legend.style.cssText = 'position:absolute;bottom:8px;left:0;right:0;display:flex;justify-content:center;gap:16px;z-index:1'; legend.innerHTML = ` Buy Sell LP Matched `; wrapper.appendChild(legend); // Canvas this._canvas = document.createElement('canvas'); this._canvas.style.cssText = 'width:100%;height:100%'; wrapper.appendChild(this._canvas); shadow.appendChild(wrapper); this._canvas.addEventListener('mousemove', (e) => this._onMouseMove(e)); this._canvas.addEventListener('mouseleave', () => { this._hovered = null; this._hoveredLp = null; }); this._resizeCanvas(); new ResizeObserver(() => this._resizeCanvas()).observe(wrapper); this._animate(); } private _resizeCanvas() { if (!this._canvas) return; const rect = this._canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; this._canvas.width = rect.width * dpr; this._canvas.height = rect.height * dpr; this._ctx = this._canvas.getContext('2d'); if (this._ctx) this._ctx.scale(dpr, dpr); } private async _fetchIntents() { try { const base = getModuleApiBase('rexchange'); const [intentsRes, poolsRes] = await Promise.all([ fetch(`${base}/api/exchange/intents`), fetch(`${base}/api/exchange/pools`), ]); if (intentsRes.ok) { const data = await intentsRes.json() as { intents: OrderIntent[] }; this._intents = data.intents || []; this._syncOrbs(); } if (poolsRes.ok) { const data = await poolsRes.json() as { positions: LpPosition[] }; this._lpPositions = data.positions || []; this._syncLpOrbs(); } const counter = this.shadowRoot?.querySelector('#intent-count'); if (counter) counter.textContent = `${this._intents.length} intents, ${this._lpPositions.length} pools`; } catch { /* silent */ } } private _syncOrbs() { const rect = this._canvas?.getBoundingClientRect(); if (!rect) return; const w = rect.width; const h = rect.height; const existingIds = new Set(this._orbs.map(o => o.intent.id)); const newIds = new Set(this._intents.map(i => i.id)); // Remove stale orbs this._orbs = this._orbs.filter(o => newIds.has(o.intent.id)); // Add new orbs for (const intent of this._intents) { if (existingIds.has(intent.id)) continue; const isBuy = intent.side === 'buy'; const baseX = isBuy ? w * 0.25 : w * 0.75; const amount = intent.tokenAmountMax / 1_000_000; const radius = Math.max(12, Math.min(30, 12 + Math.sqrt(amount) * 2)); this._orbs.push({ intent, x: baseX + (Math.random() - 0.5) * w * 0.3, y: 60 + Math.random() * (h - 120), radius, vx: (Math.random() - 0.5) * 0.3, vy: (Math.random() - 0.5) * 0.3, phase: Math.random() * Math.PI * 2, opacity: 0, hoverT: 0, color: isBuy ? BUY_COLOR : SELL_COLOR, }); } } private _syncLpOrbs() { const rect = this._canvas?.getBoundingClientRect(); if (!rect) return; const w = rect.width; const h = rect.height; const existingIds = new Set(this._lpOrbs.map(o => o.position.id)); const newIds = new Set(this._lpPositions.map(p => p.id)); // Remove stale this._lpOrbs = this._lpOrbs.filter(o => newIds.has(o.position.id)); // Add new LP orbs — centered on the divider for (const pos of this._lpPositions) { if (existingIds.has(pos.id)) continue; const totalDepth = (pos.tokenRemaining / 1_000_000) + pos.fiatRemaining; const radius = Math.max(16, Math.min(36, 16 + Math.sqrt(totalDepth) * 1.5)); this._lpOrbs.push({ position: pos, x: w * 0.5 + (Math.random() - 0.5) * 20, y: 60 + Math.random() * (h - 120), radius, vx: (Math.random() - 0.5) * 0.15, vy: (Math.random() - 0.5) * 0.15, phase: Math.random() * Math.PI * 2, opacity: 0, hoverT: 0, }); } } private _onMouseMove(e: MouseEvent) { const rect = this._canvas?.getBoundingClientRect(); if (!rect) return; const mx = e.clientX - rect.left; const my = e.clientY - rect.top; this._hovered = null; this._hoveredLp = null; // Check LP orbs first (they're on top) for (const orb of this._lpOrbs) { const dx = mx - orb.x, dy = my - orb.y; if (dx * dx + dy * dy < orb.radius * orb.radius) { this._hoveredLp = orb; break; } } if (!this._hoveredLp) { for (const orb of this._orbs) { const dx = mx - orb.x, dy = my - orb.y; if (dx * dx + dy * dy < orb.radius * orb.radius) { this._hovered = orb; break; } } } this._canvas!.style.cursor = (this._hovered || this._hoveredLp) ? 'pointer' : 'default'; } private _animate() { this._animFrame = requestAnimationFrame(() => this._animate()); this._update(); this._draw(); } private _update() { const rect = this._canvas?.getBoundingClientRect(); if (!rect) return; const w = rect.width; const h = rect.height; for (const orb of this._orbs) { orb.phase += 0.006; orb.vx += Math.sin(orb.phase) * 0.002; orb.vy += Math.cos(orb.phase * 0.8 + 1) * 0.002; orb.vx *= 0.995; orb.vy *= 0.995; orb.x += orb.vx; orb.y += orb.vy; // Constrain to side (buy=left, sell=right) const isBuy = orb.intent.side === 'buy'; const minX = isBuy ? orb.radius + 8 : w * 0.5 + orb.radius; const maxX = isBuy ? w * 0.5 - orb.radius : w - orb.radius - 8; const minY = 40 + orb.radius; const maxY = h - 40 - orb.radius; if (orb.x < minX) { orb.x = minX; orb.vx *= -0.5; } if (orb.x > maxX) { orb.x = maxX; orb.vx *= -0.5; } if (orb.y < minY) { orb.y = minY; orb.vy *= -0.5; } if (orb.y > maxY) { orb.y = maxY; orb.vy *= -0.5; } // Hover const isH = this._hovered === orb; orb.hoverT += ((isH ? 1 : 0) - orb.hoverT) * 0.12; if (orb.opacity < 1) orb.opacity = Math.min(1, orb.opacity + 0.03); } // Update LP orbs — constrained to straddle the divider for (const orb of this._lpOrbs) { orb.phase += 0.004; orb.vx += Math.sin(orb.phase) * 0.001; orb.vy += Math.cos(orb.phase * 0.7 + 1.5) * 0.001; orb.vx *= 0.99; orb.vy *= 0.99; orb.x += orb.vx; orb.y += orb.vy; // Constrain to center (spanning divider) const minX = w * 0.5 - orb.radius * 1.5; const maxX = w * 0.5 + orb.radius * 1.5; const minY = 40 + orb.radius; const maxY = h - 40 - orb.radius; if (orb.x < minX) { orb.x = minX; orb.vx *= -0.5; } if (orb.x > maxX) { orb.x = maxX; orb.vx *= -0.5; } if (orb.y < minY) { orb.y = minY; orb.vy *= -0.5; } if (orb.y > maxY) { orb.y = maxY; orb.vy *= -0.5; } const isH = this._hoveredLp === orb; orb.hoverT += ((isH ? 1 : 0) - orb.hoverT) * 0.12; if (orb.opacity < 1) orb.opacity = Math.min(1, orb.opacity + 0.03); } } private _draw() { const ctx = this._ctx; const rect = this._canvas?.getBoundingClientRect(); if (!ctx || !rect) return; const w = rect.width; const h = rect.height; ctx.clearRect(0, 0, w, h); // Divider line ctx.save(); ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(w / 2, 40); ctx.lineTo(w / 2, h - 30); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); // Side labels ctx.save(); ctx.font = '600 11px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = BUY_COLOR + '80'; ctx.fillText('BUY', w * 0.25, 35); ctx.fillStyle = SELL_COLOR + '80'; ctx.fillText('SELL', w * 0.75, 35); ctx.restore(); // Draw orbs for (const orb of this._orbs) { if (orb.opacity < 0.01) continue; ctx.save(); ctx.globalAlpha = orb.opacity; // Glow on hover if (orb.hoverT > 0.05) { ctx.shadowColor = orb.color; ctx.shadowBlur = 20 * orb.hoverT; } const r = orb.radius * (1 + orb.hoverT * 0.15); // Outer glow ctx.beginPath(); ctx.arc(orb.x, orb.y, r, 0, Math.PI * 2); ctx.fillStyle = orb.color + '15'; ctx.fill(); // Inner circle ctx.beginPath(); ctx.arc(orb.x, orb.y, r * 0.82, 0, Math.PI * 2); const g = ctx.createRadialGradient( orb.x - r * 0.15, orb.y - r * 0.15, 0, orb.x, orb.y, r * 0.82, ); g.addColorStop(0, orb.color + 'dd'); g.addColorStop(1, orb.color); ctx.fillStyle = g; ctx.fill(); ctx.shadowBlur = 0; ctx.strokeStyle = orb.color; ctx.lineWidth = 1; ctx.stroke(); // Token icon ctx.font = `${Math.max(10, r * 0.5)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#fff'; const icon = TOKEN_ICONS[orb.intent.tokenId] || '$'; ctx.fillText(icon, orb.x, orb.y); // Standing order badge if (orb.intent.isStandingOrder) { ctx.beginPath(); ctx.arc(orb.x + r * 0.65, orb.y - r * 0.65, 4, 0, Math.PI * 2); ctx.fillStyle = '#3b82f6'; ctx.fill(); } ctx.restore(); } // Draw LP orbs (on the divider) for (const orb of this._lpOrbs) { if (orb.opacity < 0.01) continue; ctx.save(); ctx.globalAlpha = orb.opacity; if (orb.hoverT > 0.05) { ctx.shadowColor = '#a78bfa'; ctx.shadowBlur = 24 * orb.hoverT; } const r = orb.radius * (1 + orb.hoverT * 0.15); // Outer glow — dual gradient ctx.beginPath(); ctx.arc(orb.x, orb.y, r, 0, Math.PI * 2); const outerGrad = ctx.createLinearGradient(orb.x - r, orb.y, orb.x + r, orb.y); outerGrad.addColorStop(0, LP_COLOR_LEFT + '20'); outerGrad.addColorStop(1, LP_COLOR_RIGHT + '20'); ctx.fillStyle = outerGrad; ctx.fill(); // Inner circle — dual-colored gradient (green left, amber right) ctx.beginPath(); ctx.arc(orb.x, orb.y, r * 0.82, 0, Math.PI * 2); const innerGrad = ctx.createLinearGradient(orb.x - r, orb.y, orb.x + r, orb.y); innerGrad.addColorStop(0, LP_COLOR_LEFT); innerGrad.addColorStop(0.5, '#34d399'); innerGrad.addColorStop(1, LP_COLOR_RIGHT); ctx.fillStyle = innerGrad; ctx.fill(); ctx.shadowBlur = 0; // Border gradient ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 1.5; ctx.stroke(); // Token icon ctx.font = `${Math.max(10, r * 0.45)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#fff'; const icon = TOKEN_ICONS[orb.position.tokenId] || '$'; ctx.fillText(icon, orb.x, orb.y); // LP badge dot ctx.beginPath(); ctx.arc(orb.x + r * 0.65, orb.y - r * 0.65, 5, 0, Math.PI * 2); ctx.fillStyle = '#8b5cf6'; ctx.fill(); ctx.strokeStyle = '#0f172a'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } // Hover tooltip — LP positions if (this._hoveredLp) { const orb = this._hoveredLp; const p = orb.position; const tokenDepth = (p.tokenRemaining / 1_000_000).toFixed(2); const fiatSymbol = p.fiatCurrency === 'USD' ? '$' : p.fiatCurrency === 'EUR' ? '€' : p.fiatCurrency === 'GBP' ? '£' : ''; const earned = `${fiatSymbol}${p.feesEarnedFiat.toFixed(2)}`; const lines = [ `${p.creatorName} (LP)`, `${p.tokenId}/${p.fiatCurrency}`, `Depth: ${tokenDepth} / ${fiatSymbol}${p.fiatRemaining.toFixed(2)}`, `Spread: ${p.spreadBps}bps`, `Earned: ${earned} | ${p.tradesMatched} trades`, ]; const tooltipX = orb.x + orb.radius + 12; const tooltipY = Math.max(50, Math.min(h - 100, orb.y - 40)); ctx.save(); ctx.fillStyle = '#1e293bee'; const tw = 180; const th = lines.length * 16 + 12; const tx = tooltipX + tw > w ? orb.x - orb.radius - tw - 12 : tooltipX; ctx.beginPath(); ctx.roundRect(tx, tooltipY, tw, th, 6); ctx.fill(); ctx.font = '600 11px system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; for (let l = 0; l < lines.length; l++) { ctx.fillStyle = l === 0 ? '#a78bfa' : MUTED_COLOR; ctx.font = l === 0 ? '600 11px system-ui, sans-serif' : '11px system-ui, sans-serif'; ctx.fillText(lines[l], tx + 8, tooltipY + 6 + l * 16); } ctx.restore(); } // Hover tooltip if (this._hovered) { const orb = this._hovered; const i = orb.intent; const amount = i.tokenAmountMax / 1_000_000; const rateStr = i.rateType === 'fixed' ? `${i.rateFixed} ${i.fiatCurrency}` : `market+${i.rateMarketBps}bps`; const lines = [ i.creatorName, `${i.side.toUpperCase()} ${amount.toFixed(2)} ${i.tokenId}`, `Rate: ${rateStr}`, `Pay: ${i.paymentMethods.join(', ')}`, ]; const tooltipX = orb.x + orb.radius + 12; const tooltipY = Math.max(50, Math.min(h - 80, orb.y - 30)); ctx.save(); ctx.fillStyle = '#1e293bee'; const tw = 160; const th = lines.length * 16 + 12; const tx = tooltipX + tw > w ? orb.x - orb.radius - tw - 12 : tooltipX; ctx.beginPath(); ctx.roundRect(tx, tooltipY, tw, th, 6); ctx.fill(); ctx.font = '600 11px system-ui, sans-serif'; ctx.fillStyle = TEXT_COLOR; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; for (let l = 0; l < lines.length; l++) { ctx.fillStyle = l === 0 ? TEXT_COLOR : MUTED_COLOR; ctx.font = l === 0 ? '600 11px system-ui, sans-serif' : '11px system-ui, sans-serif'; ctx.fillText(lines[l], tx + 8, tooltipY + 6 + l * 16); } ctx.restore(); } } } if (typeof customElements !== 'undefined' && !customElements.get('folk-exchange-node')) { customElements.define('folk-exchange-node', FolkExchangeNode); }