373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
/**
|
|
* 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 MATCH_ARC_COLOR = '#60a5fa';
|
|
const BG_COLOR = '#0f172a';
|
|
const TEXT_COLOR = '#e2e8f0';
|
|
const MUTED_COLOR = '#64748b';
|
|
|
|
const TOKEN_ICONS: Record<string, string> = {
|
|
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 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 _hovered: Orb | null = null;
|
|
private _animFrame = 0;
|
|
private _pollTimer = 0;
|
|
private _spaceSlug = 'demo';
|
|
private _intents: OrderIntent[] = [];
|
|
|
|
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 = `
|
|
<span style="font-size:0.85rem;font-weight:600;color:${TEXT_COLOR}">💱 rExchange</span>
|
|
<span style="font-size:0.7rem;color:${MUTED_COLOR}" id="intent-count">0 intents</span>
|
|
`;
|
|
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 = `
|
|
<span style="font-size:0.7rem;color:${BUY_COLOR};display:flex;align-items:center;gap:4px">
|
|
<span style="width:8px;height:8px;border-radius:50%;background:${BUY_COLOR};display:inline-block"></span> Buy
|
|
</span>
|
|
<span style="font-size:0.7rem;color:${SELL_COLOR};display:flex;align-items:center;gap:4px">
|
|
<span style="width:8px;height:8px;border-radius:50%;background:${SELL_COLOR};display:inline-block"></span> Sell
|
|
</span>
|
|
<span style="font-size:0.7rem;color:${MATCH_ARC_COLOR};display:flex;align-items:center;gap:4px">
|
|
<span style="width:8px;height:1px;background:${MATCH_ARC_COLOR};display:inline-block"></span> Matched
|
|
</span>
|
|
`;
|
|
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._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 res = await fetch(`${base}/api/exchange/intents`);
|
|
if (!res.ok) return;
|
|
const data = await res.json() as { intents: OrderIntent[] };
|
|
this._intents = data.intents || [];
|
|
this._syncOrbs();
|
|
|
|
const counter = this.shadowRoot?.querySelector('#intent-count');
|
|
if (counter) counter.textContent = `${this._intents.length} intents`;
|
|
} 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 _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;
|
|
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 ? '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);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// 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);
|
|
}
|