| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>RugMaps β Wallet Cluster Intelligence</title> |
| <style> |
| :root { |
| --bg: #0a0a0f; --panel: #12121a; --border: #1e1e2e; |
| --text: #e0e0e0; --dim: #8888aa; --accent: #7c3aed; --accent2: #06b6d4; |
| --green: #00ff88; --yellow: #ffd700; --orange: #ff8800; --red: #ff0044; |
| } |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; min-height: 100vh; overflow-x: hidden; } |
| header { background: var(--panel); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 100; } |
| .logo { font-size: 20px; font-weight: 800; letter-spacing: -0.5px; white-space: nowrap; } |
| .logo span:first-child { color: var(--accent2); } .logo span:last-child { color: var(--text); } |
| nav { display: flex; gap: 4px; } |
| nav a { color: var(--dim); text-decoration: none; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 500; } |
| nav a:hover, nav a.active { color: var(--text); background: rgba(255,255,255,0.06); } |
| nav a.active { color: var(--accent2); } |
| .search-bar { flex: 1; max-width: 500px; display: flex; gap: 8px; margin-left: auto; } |
| .search-bar input { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px; color: var(--text); font-size: 13px; outline: none; } |
| .search-bar input:focus { border-color: var(--accent2); } |
| .search-bar select { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: var(--text); font-size: 13px; } |
| .search-bar button { background: var(--accent2); color: #000; border: none; border-radius: 8px; padding: 8px 18px; font-size: 13px; font-weight: 700; cursor: pointer; } |
| .search-bar button:hover { opacity: 0.9; } |
| .container { display: flex; height: calc(100vh - 57px); } |
| #graphArea { flex: 1; position: relative; background: radial-gradient(ellipse at center, #12122a 0%, var(--bg) 70%); } |
| #graphArea canvas { position: absolute; top: 0; left: 0; } |
| #sidebar { width: 380px; background: var(--panel); border-left: 1px solid var(--border); overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; } |
| .sidebar-section { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; } |
| .sidebar-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--dim); margin-bottom: 10px; } |
| .stat { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; } |
| .stat-label { color: var(--dim); } |
| .stat-value { font-weight: 600; } |
| .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; margin: 1px; } |
| .tag.high { background: rgba(255,0,68,0.15); color: var(--red); } |
| .tag.medium { background: rgba(255,136,0,0.15); color: var(--orange); } |
| .tag.low { background: rgba(0,255,136,0.15); color: var(--green); } |
| .tag.entity { background: rgba(6,182,212,0.15); color: var(--accent2); } |
| .bundle-list { max-height: 250px; overflow-y: auto; } |
| .bundle-item { padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.04); cursor: pointer; border-radius: 4px; margin-bottom: 4px; font-size: 12px; } |
| .bundle-item:hover { background: rgba(255,255,255,0.04); } |
| .bundle-item.selected { background: rgba(6,182,212,0.1); border: 1px solid var(--accent2); } |
| .wallet-badge { font-family: monospace; font-size: 11px; color: var(--dim); } |
| .legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 11px; } |
| .legend-item { display: flex; align-items: center; gap: 4px; } |
| .legend-dot { width: 8px; height: 8px; border-radius: 50%; } |
| .tooltip { position: absolute; background: var(--panel); border: 1px solid var(--accent2); border-radius: 8px; padding: 10px 14px; font-size: 12px; pointer-events: none; display: none; z-index: 1000; max-width: 280px; } |
| .tooltip .addr { font-family: monospace; font-size: 11px; color: var(--accent2); margin-bottom: 4px; } |
| .tooltip .entity { color: var(--green); font-weight: 600; } |
| .loading-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); text-align: center; color: var(--dim); } |
| .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--accent2); border-radius: 50%; animation: spin 0.8s linear infinite; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .empty-state { text-align: center; color: var(--dim); padding: 40px; } |
| .empty-state h2 { color: var(--text); margin-bottom: 8px; } |
| </style> |
| </head> |
| <body> |
|
|
| <header> |
| <div class="logo"><span>Rug</span><span>Maps</span></div> |
| <nav> |
| <a href="/rugcharts">RugCharts</a> |
| <a href="/rugmaps" class="active">RugMaps</a> |
| <a href="/news">News</a> |
| </nav> |
| <div class="search-bar"> |
| <input id="tokenInput" type="text" placeholder="Token address for cluster analysis..." autofocus> |
| <select id="chainSelect"> |
| <option value="solana">Solana</option> |
| <option value="ethereum">Ethereum</option> |
| <option value="base">Base</option> |
| <option value="bsc">BSC</option> |
| </select> |
| <button onclick="scanToken()">Map</button> |
| </div> |
| </header> |
|
|
| <div class="container"> |
| <div id="graphArea"> |
| <canvas id="graphCanvas"></canvas> |
| <div class="loading-overlay" id="loadingOverlay" style="display:none"> |
| <div class="spinner"></div> |
| <div style="margin-top:12px" id="loadingText">Mapping wallet clusters...</div> |
| </div> |
| <div class="empty-state" id="emptyState"> |
| <h2>RugMaps β Wallet Cluster Intelligence</h2> |
| <p>Paste a token address to visualize wallet clusters,<br>bundle detection, and entity relationships.</p> |
| <p style="margin-top:8px;font-size:12px;color:var(--dim)">Powered by DataBus: bundle_detect + cluster_map + Arkham</p> |
| </div> |
| <div class="tooltip" id="tooltip"></div> |
| </div> |
|
|
| <div id="sidebar"> |
| <div class="sidebar-section"> |
| <div class="sidebar-title">π Cluster Overview</div> |
| <div id="overviewStats"> |
| <div class="stat"><span class="stat-label">Scanning...</span></div> |
| </div> |
| </div> |
|
|
| <div class="sidebar-section"> |
| <div class="sidebar-title">π Wallet Bundles</div> |
| <div class="legend"> |
| <div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div>High Risk</div> |
| <div class="legend-item"><div class="legend-dot" style="background:var(--orange)"></div>Medium</div> |
| <div class="legend-item"><div class="legend-dot" style="background:var(--accent2)"></div>Entity</div> |
| <div class="legend-item"><div class="legend-dot" style="background:var(--dim)"></div>Wallet</div> |
| </div> |
| <div class="bundle-list" id="bundleList"></div> |
| </div> |
|
|
| <div class="sidebar-section"> |
| <div class="sidebar-title">π·οΈ Selected Wallet</div> |
| <div id="walletDetail" style="font-size:12px;color:var(--dim)">Click a node for details</div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const API = '/api/v1/databus'; |
| |
| let nodes = [], edges = [], bundles = []; |
| let highlightedBundle = null; |
| |
| async function apiGet(path) { |
| try { const r = await fetch(API + path); return r.ok ? r.json() : null; } |
| catch(e) { return null; } |
| } |
| |
| async function scanToken() { |
| const addr = document.getElementById('tokenInput').value.trim(); |
| const chain = document.getElementById('chainSelect').value; |
| if (!addr) return; |
| |
| document.getElementById('emptyState').style.display = 'none'; |
| document.getElementById('loadingOverlay').style.display = 'block'; |
| document.getElementById('loadingText').textContent = 'Detecting wallet bundles...'; |
| |
| const token = encodeURIComponent(addr); |
| |
| |
| const [bundleData, clusterData] = await Promise.all([ |
| apiGet(`/premium/bundles/${token}?chain=${chain}`), |
| apiGet(`/premium/clusters/${token}?chain=${chain}&depth=3`), |
| ]); |
| |
| bundles = bundleData?.bundles || []; |
| const clusterNodes = clusterData?.nodes || []; |
| const clusterEdges = clusterData?.edges || []; |
| |
| document.getElementById('loadingText').textContent = 'Building graph...'; |
| |
| |
| nodes = []; |
| edges = []; |
| const nodeMap = new Map(); |
| const bundleWallets = new Set(); |
| |
| |
| for (const b of bundles) { |
| for (const w of (b.wallets || [])) { |
| bundleWallets.add(w); |
| } |
| } |
| |
| |
| for (const n of clusterNodes) { |
| const inBundle = bundleWallets.has(n.id); |
| const hasEntity = n.entity && n.entity.length > 0; |
| nodes.push({ |
| id: n.id, |
| entity: n.entity || '', |
| type: hasEntity ? 'entity' : (inBundle ? 'bundle' : 'wallet'), |
| depth: n.depth || 0, |
| x: Math.random() * 600 + 100, |
| y: Math.random() * 400 + 100, |
| vx: 0, vy: 0, |
| }); |
| nodeMap.set(n.id, nodes[nodes.length - 1]); |
| } |
| |
| |
| for (const w of bundleWallets) { |
| if (!nodeMap.has(w)) { |
| nodes.push({ |
| id: w, |
| entity: '', |
| type: 'bundle', |
| depth: 0, |
| x: Math.random() * 600 + 100, |
| y: Math.random() * 400 + 100, |
| vx: 0, vy: 0, |
| }); |
| nodeMap.set(w, nodes[nodes.length - 1]); |
| } |
| } |
| |
| |
| for (const e of clusterEdges) { |
| if (nodeMap.has(e.from) && nodeMap.has(e.to)) { |
| edges.push({ from: e.from, to: e.to, value: e.value || 1 }); |
| } |
| } |
| |
| |
| for (const b of bundles) { |
| const wallets = b.wallets || []; |
| for (let i = 0; i < wallets.length; i++) { |
| for (let j = i + 1; j < wallets.length; j++) { |
| if (nodeMap.has(wallets[i]) && nodeMap.has(wallets[j])) { |
| const existingEdge = edges.find(e => |
| (e.from === wallets[i] && e.to === wallets[j]) || |
| (e.from === wallets[j] && e.to === wallets[i])); |
| if (!existingEdge) { |
| edges.push({ from: wallets[i], to: wallets[j], value: 2, bundle: true }); |
| } |
| } |
| } |
| } |
| } |
| |
| document.getElementById('loadingOverlay').style.display = 'none'; |
| |
| |
| updateSidebar(bundleData, clusterData); |
| |
| |
| highlightedBundle = null; |
| renderGraph(); |
| |
| history.pushState({}, '', `?chain=${chain}&token=${addr}`); |
| } |
| |
| function updateSidebar(bundleData, clusterData) { |
| const stats = document.getElementById('overviewStats'); |
| const totalNodes = nodes.length; |
| const bundleNodes = nodes.filter(n => n.type === 'bundle').length; |
| const entityNodes = nodes.filter(n => n.type === 'entity').length; |
| const totalBundles = bundles.length; |
| const maxBundle = bundles.reduce((m, b) => Math.max(m, (b.wallets||[]).length), 0); |
| |
| stats.innerHTML = ` |
| <div class="stat"><span class="stat-label">Total Wallets</span><span class="stat-value">${totalNodes}</span></div> |
| <div class="stat"><span class="stat-label">Clusters Found</span><span class="stat-value">${totalBundles}</span></div> |
| <div class="stat"><span class="stat-label">Bundle Wallets</span><span class="stat-value" style="color:${bundleNodes>0?'var(--orange)':'var(--dim)'}">${bundleNodes}</span></div> |
| <div class="stat"><span class="stat-label">Known Entities</span><span class="stat-value" style="color:var(--accent2)">${entityNodes}</span></div> |
| <div class="stat"><span class="stat-label">Largest Bundle</span><span class="stat-value">${maxBundle} wallets</span></div> |
| <div class="stat"><span class="stat-label">Total Edges</span><span class="stat-value">${edges.length}</span></div> |
| <div class="stat"><span class="stat-label">Source</span><span class="stat-value" style="color:var(--accent2)">${clusterData?.source || 'databus'}</span></div> |
| `; |
| |
| |
| const bl = document.getElementById('bundleList'); |
| if (!bundles.length) { |
| bl.innerHTML = '<div style="color:var(--dim);font-size:12px;padding:8px">No coordinated bundles detected. Wallets appear independent.</div>'; |
| } else { |
| bl.innerHTML = bundles.map((b, i) => ` |
| <div class="bundle-item" onclick="highlightBundle(${i})" id="bundle-${i}"> |
| <div style="display:flex;justify-content:space-between;align-items:center"> |
| <span>Bundle #${i+1}</span> |
| <span class="tag ${b.risk_level==='HIGH'?'high':'medium'}">${b.risk_level||'MEDIUM'}</span> |
| </div> |
| <div class="wallet-badge">${(b.wallets||[]).length} wallets Β· score: ${(b.coordination_score||0).toFixed(1)}</div> |
| ${b.pattern ? `<div style="color:var(--dim);font-size:10px">${b.pattern}</div>` : ''} |
| </div> |
| `).join(''); |
| } |
| } |
| |
| function highlightBundle(idx) { |
| highlightedBundle = highlightedBundle === idx ? null : idx; |
| document.querySelectorAll('.bundle-item').forEach(el => el.classList.remove('selected')); |
| if (highlightedBundle !== null) { |
| document.getElementById(`bundle-${idx}`)?.classList.add('selected'); |
| } |
| renderGraph(); |
| } |
| |
| |
| const canvas = document.getElementById('graphCanvas'); |
| const ctx = canvas.getContext('2d'); |
| let animationId; |
| |
| function resize() { |
| const area = document.getElementById('graphArea'); |
| canvas.width = area.clientWidth; |
| canvas.height = area.clientHeight; |
| } |
| window.addEventListener('resize', () => { resize(); renderGraph(); }); |
| resize(); |
| |
| function nodeColor(node) { |
| if (node.type === 'entity') return '#06b6d4'; |
| if (node.type === 'bundle') return '#ff8800'; |
| return '#555577'; |
| } |
| |
| function nodeRadius(node) { |
| if (node.type === 'entity') return 8; |
| if (node.type === 'bundle') return 6; |
| return 4; |
| } |
| |
| function renderGraph() { |
| if (!nodes.length) { |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| return; |
| } |
| |
| |
| const iterations = 50; |
| const centerX = canvas.width / 2; |
| const centerY = canvas.height / 2; |
| const repulsion = 5000; |
| const attraction = 0.005; |
| const damping = 0.85; |
| |
| for (let iter = 0; iter < iterations; iter++) { |
| |
| for (let i = 0; i < nodes.length; i++) { |
| for (let j = i + 1; j < nodes.length; j++) { |
| const dx = nodes[i].x - nodes[j].x; |
| const dy = nodes[i].y - nodes[j].y; |
| const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy)); |
| const force = repulsion / (dist * dist); |
| const fx = (dx / dist) * force; |
| const fy = (dy / dist) * force; |
| nodes[i].vx += fx; |
| nodes[i].vy += fy; |
| nodes[j].vx -= fx; |
| nodes[j].vy -= fy; |
| } |
| } |
| |
| |
| for (const e of edges) { |
| const from = nodes.find(n => n.id === e.from); |
| const to = nodes.find(n => n.id === e.to); |
| if (!from || !to) continue; |
| const dx = to.x - from.x; |
| const dy = to.y - from.y; |
| const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy)); |
| const force = dist * attraction * (e.value || 1); |
| const fx = (dx / dist) * force; |
| const fy = (dy / dist) * force; |
| from.vx += fx; |
| from.vy += fy; |
| to.vx -= fx; |
| to.vy -= fy; |
| } |
| |
| |
| for (const n of nodes) { |
| n.vx += (centerX - n.x) * 0.001; |
| n.vy += (centerY - n.y) * 0.001; |
| } |
| |
| |
| for (const n of nodes) { |
| n.x += n.vx * damping; |
| n.y += n.vy * damping; |
| n.vx *= damping; |
| n.vy *= damping; |
| |
| |
| n.x = Math.max(20, Math.min(canvas.width - 20, n.x)); |
| n.y = Math.max(20, Math.min(canvas.height - 20, n.y)); |
| } |
| } |
| |
| |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| |
| |
| if (highlightedBundle !== null && bundles[highlightedBundle]) { |
| const bw = bundles[highlightedBundle].wallets || []; |
| const bundleNodes = nodes.filter(n => bw.includes(n.id)); |
| if (bundleNodes.length) { |
| const cx = bundleNodes.reduce((s, n) => s + n.x, 0) / bundleNodes.length; |
| const cy = bundleNodes.reduce((s, n) => s + n.y, 0) / bundleNodes.length; |
| const maxR = Math.max(...bundleNodes.map(n => |
| Math.sqrt((n.x-cx)**2 + (n.y-cy)**2))) + 30; |
| ctx.beginPath(); |
| ctx.arc(cx, cy, maxR, 0, Math.PI * 2); |
| ctx.fillStyle = 'rgba(255,136,0,0.08)'; |
| ctx.fill(); |
| ctx.strokeStyle = 'rgba(255,136,0,0.3)'; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
| } |
| } |
| |
| |
| for (const e of edges) { |
| const from = nodes.find(n => n.id === e.from); |
| const to = nodes.find(n => n.id === e.to); |
| if (!from || !to) continue; |
| |
| const isBundleEdge = e.bundle; |
| const isHighlighted = highlightedBundle !== null && bundles[highlightedBundle] && |
| bundles[highlightedBundle].wallets?.includes(e.from) && |
| bundles[highlightedBundle].wallets?.includes(e.to); |
| |
| ctx.beginPath(); |
| ctx.moveTo(from.x, from.y); |
| ctx.lineTo(to.x, to.y); |
| ctx.strokeStyle = isHighlighted ? 'rgba(255,136,0,0.6)' : |
| isBundleEdge ? 'rgba(255,136,0,0.2)' : 'rgba(255,255,255,0.06)'; |
| ctx.lineWidth = isHighlighted ? 2 : (isBundleEdge ? 1.5 : 0.5); |
| ctx.stroke(); |
| } |
| |
| |
| for (const n of nodes) { |
| const isHighlighted = highlightedBundle !== null && bundles[highlightedBundle] && |
| bundles[highlightedBundle].wallets?.includes(n.id); |
| |
| ctx.beginPath(); |
| ctx.arc(n.x, n.y, nodeRadius(n) * (isHighlighted ? 1.5 : 1), 0, Math.PI * 2); |
| ctx.fillStyle = isHighlighted ? '#ff8800' : nodeColor(n); |
| ctx.fill(); |
| |
| if (isHighlighted || n.type === 'entity') { |
| ctx.strokeStyle = isHighlighted ? '#fff' : 'rgba(6,182,212,0.5)'; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| } |
| |
| |
| if (n.entity && (n.type === 'entity' || isHighlighted)) { |
| ctx.fillStyle = '#fff'; |
| ctx.font = '10px -apple-system, sans-serif'; |
| ctx.fillText(n.entity.slice(0, 12), n.x + 10, n.y + 3); |
| } |
| } |
| } |
| |
| |
| const tooltip = document.getElementById('tooltip'); |
| |
| canvas.addEventListener('mousemove', (e) => { |
| const rect = canvas.getBoundingClientRect(); |
| const mx = e.clientX - rect.left; |
| const my = e.clientY - rect.top; |
| |
| let closest = null; |
| let closestDist = 25; |
| |
| for (const n of nodes) { |
| const dx = n.x - mx; |
| const dy = n.y - my; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < closestDist) { |
| closestDist = dist; |
| closest = n; |
| } |
| } |
| |
| if (closest) { |
| canvas.style.cursor = 'pointer'; |
| tooltip.style.display = 'block'; |
| tooltip.style.left = (e.clientX - rect.left + 15) + 'px'; |
| tooltip.style.top = (e.clientY - rect.top - 10) + 'px'; |
| tooltip.innerHTML = ` |
| <div class="addr">${closest.id.slice(0,8)}...${closest.id.slice(-4)}</div> |
| ${closest.entity ? `<div class="entity">${closest.entity}</div>` : ''} |
| <div style="color:var(--dim)">Type: ${closest.type} Β· Depth: ${closest.depth}</div> |
| `; |
| |
| document.getElementById('walletDetail').innerHTML = ` |
| <div style="font-family:monospace;color:var(--accent2);margin-bottom:6px">${closest.id}</div> |
| ${closest.entity ? `<div class="tag entity">${closest.entity}</div>` : ''} |
| <div class="stat"><span class="stat-label">Type</span><span class="stat-value">${closest.type}</span></div> |
| <div class="stat"><span class="stat-label">Depth</span><span class="stat-value">${closest.depth}</span></div> |
| <div class="stat"><span class="stat-label">Connections</span><span class="stat-value">${edges.filter(e => e.from===closest.id || e.to===closest.id).length}</span></div> |
| `; |
| } else { |
| canvas.style.cursor = 'default'; |
| tooltip.style.display = 'none'; |
| } |
| }); |
| |
| canvas.addEventListener('click', (e) => { |
| const rect = canvas.getBoundingClientRect(); |
| const mx = e.clientX - rect.left; |
| const my = e.clientY - rect.top; |
| |
| for (const n of nodes) { |
| const dx = n.x - mx; |
| const dy = n.y - my; |
| if (Math.sqrt(dx*dx + dy*dy) < nodeRadius(n) + 5) { |
| |
| for (let i = 0; i < bundles.length; i++) { |
| if ((bundles[i].wallets||[]).includes(n.id)) { |
| highlightBundle(i); |
| return; |
| } |
| } |
| |
| highlightedBundle = null; |
| document.querySelectorAll('.bundle-item').forEach(el => el.classList.remove('selected')); |
| renderGraph(); |
| return; |
| } |
| } |
| }); |
| |
| async function init() { |
| const params = new URLSearchParams(window.location.search); |
| const token = params.get('token'); |
| const chain = params.get('chain'); |
| if (token) { |
| document.getElementById('tokenInput').value = token; |
| if (chain) document.getElementById('chainSelect').value = chain; |
| scanToken(); |
| } |
| |
| document.getElementById('tokenInput').addEventListener('keydown', e => { |
| if (e.key === 'Enter') scanToken(); |
| }); |
| } |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|