Spaces:
Running
Running
| <html lang="es"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Dashboard: 7d (XRP, PAXG, WLD, USDC) + Earn Morpho (USDC)</title> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <style> | |
| body { font-family: Inter, system-ui, Arial; margin: 18px; background:#f7f8fb; color:#111; } | |
| header { display:flex; gap:12px; align-items:center; margin-bottom:12px; flex-wrap:wrap; } | |
| .card { background:white; border-radius:10px; padding:12px; box-shadow:0 6px 18px rgba(20,20,40,0.06); } | |
| .small { font-size:0.9rem; color:#555; } | |
| #grid { display:grid; grid-template-columns: 1fr 320px; gap:12px; align-items:start; } | |
| .charts { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:12px; } | |
| .chartWrap { height: 260px; position:relative; } | |
| .banner { | |
| display:none; margin-bottom:10px; padding:10px 12px; | |
| background:#fff7ed; border:1px solid #fed7aa; color:#9a3412; | |
| border-radius:10px; font-size:0.95rem; | |
| } | |
| input[type="number"]{ width:120px; padding:6px; border-radius:8px; border:1px solid #ddd; } | |
| select { padding:6px; border-radius:8px; border:1px solid #ddd; background:white; } | |
| button { padding:8px 10px; border-radius:8px; border:none; background:#2563eb; color:white; cursor:pointer; } | |
| button.secondary { background:#e5e7eb; color:#111; } | |
| .row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; } | |
| .muted { color:#6b7280; font-size:0.85rem; } | |
| @media (max-width: 980px){ | |
| #grid { grid-template-columns: 1fr; } | |
| .charts { grid-template-columns: 1fr; } | |
| .chartWrap { height: 240px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h2 style="margin:0;">Crypto 7d — 4 gráficas (XRP, PAXG, WLD, USDC)</h2> | |
| <div class="small">Precios: CoinGecko · Realtime por polling · Morpho estimator (ejemplo).</div> | |
| </header> | |
| <div id="grid"> | |
| <div class="charts" id="charts"></div> | |
| <div class="card"> | |
| <h3 style="margin-top:0;">Realtime (polling)</h3> | |
| <div class="small" style="margin-bottom:8px;"> | |
| Última actualización: <strong id="lastUpdate">-</strong> | |
| </div> | |
| <div class="row" style="margin-bottom:10px;"> | |
| <label class="small">Intervalo:</label> | |
| <select id="pollSelect"> | |
| <option value="10">10s</option> | |
| <option value="20" selected>20s</option> | |
| <option value="30">30s</option> | |
| <option value="60">60s</option> | |
| </select> | |
| <button id="toggleLiveBtn">Iniciar</button> | |
| <button id="refreshNowBtn" class="secondary">Actualizar ahora</button> | |
| </div> | |
| <div class="muted">Recomendado: 30–60s para máxima estabilidad (menos 429).</div> | |
| <hr style="margin:12px 0;" /> | |
| <h3 style="margin:0 0 6px;">Earn Morpho — Estimador (USDC)</h3> | |
| <div class="small" style="margin-bottom:8px;"> | |
| Tasa usada (APY referencia): <span id="morphoRateDisplay">4.00%</span> | |
| </div> | |
| <label class="small">Monto en USDC: | |
| <input id="amount" type="number" min="0" step="0.01" value="1000"> | |
| </label> | |
| <div class="row" style="margin-top:8px;"> | |
| <button id="calcBtn">Calcular (7 días)</button> | |
| <button id="setRateBtn" class="secondary">Usar otra tasa</button> | |
| </div> | |
| <div id="results" style="margin-top:12px;"> | |
| <div class="small">Ganancia estimada (7 días): <strong id="gain7">-</strong></div> | |
| <div class="small">Total aproximado: <strong id="total7">-</strong></div> | |
| <div class="muted" style="margin-top:6px;">(Interés compuesto diario aplicado sobre APY)</div> | |
| </div> | |
| <div class="muted" style="margin-top:12px;"> | |
| Nota: CoinGecko API pública se usa para datos históricos. Ajusta la tasa Morpho según tu fuente. | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| /** IDs correctos CoinGecko */ | |
| const ASSETS = [ | |
| { id: 'ripple', label: 'XRP', color: '#1e90ff' }, | |
| { id: 'pax-gold', label: 'PAXG', color: '#bfa34a' }, | |
| { id: 'worldcoin-wld', label: 'WLD', color: '#7c3aed' }, | |
| { id: 'usd-coin', label: 'USDC', color: '#16a34a' }, | |
| ]; | |
| const chartsById = new Map(); // assetId -> { chart, labels, data, hasLivePoint } | |
| let liveTimer = null; | |
| function tsToDateLabel(ts) { | |
| const d = new Date(ts); | |
| return d.toLocaleDateString(); | |
| } | |
| function timeLabelNow() { | |
| const d = new Date(); | |
| return 'Ahora ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); | |
| } | |
| async function fetchMarketChart7d(assetId) { | |
| const url = `https://api.coingecko.com/api/v3/coins/${assetId}/market_chart?vs_currency=usd&days=7&interval=daily`; | |
| const res = await fetch(url); | |
| if (!res.ok) throw new Error(`${assetId} -> ${res.status}`); | |
| const j = await res.json(); | |
| return j.prices.map(p => ({ t: p[0], price: p[1] })); // [timestamp, price] | |
| } | |
| async function fetchSimplePrices(ids) { | |
| const qs = encodeURIComponent(ids.join(',')); | |
| const url = `https://api.coingecko.com/api/v3/simple/price?ids=${qs}&vs_currencies=usd&include_last_updated_at=true`; | |
| const res = await fetch(url); | |
| if (!res.ok) throw new Error(`simple/price -> ${res.status}`); | |
| return res.json(); | |
| } | |
| function makeCard(asset) { | |
| const el = document.createElement('div'); | |
| el.className = 'card'; | |
| el.innerHTML = ` | |
| <div style="display:flex; align-items:baseline; justify-content:space-between; gap:10px; flex-wrap:wrap;"> | |
| <div> | |
| <div style="font-size:1.05rem; font-weight:700;">${asset.label}</div> | |
| <div class="muted">${asset.id}</div> | |
| </div> | |
| <div class="small">USD</div> | |
| </div> | |
| <div class="banner" id="err_${asset.id}"></div> | |
| <div class="chartWrap"><canvas id="c_${asset.id}"></canvas></div> | |
| `; | |
| return el; | |
| } | |
| function showAssetError(assetId, msg) { | |
| const box = document.getElementById(`err_${assetId}`); | |
| if (!box) return; | |
| box.style.display = 'block'; | |
| box.textContent = msg; | |
| } | |
| function hideAssetError(assetId) { | |
| const box = document.getElementById(`err_${assetId}`); | |
| if (!box) return; | |
| box.style.display = 'none'; | |
| box.textContent = ''; | |
| } | |
| function createChart(asset, labels, data) { | |
| const ctx = document.getElementById(`c_${asset.id}`).getContext('2d'); | |
| const chart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels, | |
| datasets: [{ | |
| label: asset.label, | |
| data, | |
| borderColor: asset.color, | |
| backgroundColor: asset.color, | |
| tension: 0.2, | |
| fill: false, | |
| pointRadius: 2.5 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| interaction: { mode: 'index', intersect: false }, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| x: { ticks: { maxRotation: 0 } }, | |
| y: { ticks: { callback: (v) => v } } | |
| } | |
| } | |
| }); | |
| chartsById.set(asset.id, { chart, labels, data, hasLivePoint: false }); | |
| } | |
| function upsertLivePoint(assetId, price) { | |
| const entry = chartsById.get(assetId); | |
| if (!entry) return; | |
| if (!entry.hasLivePoint) { | |
| entry.labels.push(timeLabelNow()); | |
| entry.data.push(price); | |
| entry.hasLivePoint = true; | |
| } else { | |
| entry.labels[entry.labels.length - 1] = timeLabelNow(); | |
| entry.data[entry.data.length - 1] = price; | |
| } | |
| // Mantener puntaje razonable (histórico ~8 + live) | |
| while (entry.labels.length > 9) { | |
| entry.labels.shift(); | |
| entry.data.shift(); | |
| } | |
| entry.chart.update(); | |
| } | |
| function setLastUpdate(ts) { | |
| const el = document.getElementById('lastUpdate'); | |
| if (!ts) { el.textContent = '-'; return; } | |
| const d = new Date(ts * 1000); | |
| el.textContent = d.toLocaleString(); | |
| } | |
| async function refreshLiveOnce() { | |
| const ids = ASSETS.map(a => a.id); | |
| const j = await fetchSimplePrices(ids); | |
| let newest = 0; | |
| ASSETS.forEach(a => { | |
| const row = j[a.id]; | |
| if (!row || typeof row.usd !== 'number') return; | |
| hideAssetError(a.id); | |
| upsertLivePoint(a.id, row.usd); | |
| if (row.last_updated_at && row.last_updated_at > newest) newest = row.last_updated_at; | |
| }); | |
| if (newest) setLastUpdate(newest); | |
| } | |
| function getSafeIntervalSeconds() { | |
| const allowed = new Set([10, 20, 30, 60]); | |
| const raw = parseInt(document.getElementById('pollSelect').value, 10); | |
| const chosen = allowed.has(raw) ? raw : 20; | |
| // “Más estable”: mínimo recomendado 20s | |
| return Math.max(20, chosen); | |
| } | |
| function stopLive() { | |
| if (liveTimer) clearInterval(liveTimer); | |
| liveTimer = null; | |
| document.getElementById('toggleLiveBtn').textContent = 'Iniciar'; | |
| } | |
| async function startLive() { | |
| stopLive(); | |
| try { await refreshLiveOnce(); } catch (e) { console.warn(e); } | |
| const s = getSafeIntervalSeconds(); | |
| liveTimer = setInterval(async () => { | |
| try { await refreshLiveOnce(); } catch (e) { console.warn(e); } | |
| }, s * 1000); | |
| document.getElementById('toggleLiveBtn').textContent = 'Detener'; | |
| } | |
| // Re-inicia automáticamente si cambias intervalo mientras está corriendo | |
| document.getElementById('pollSelect').addEventListener('change', () => { | |
| if (liveTimer) startLive(); | |
| }); | |
| document.getElementById('refreshNowBtn').addEventListener('click', async () => { | |
| try { await refreshLiveOnce(); } catch (e) { console.warn(e); } | |
| }); | |
| document.getElementById('toggleLiveBtn').addEventListener('click', () => { | |
| if (liveTimer) stopLive(); | |
| else startLive(); | |
| }); | |
| // --- Morpho estimator --- | |
| let morphoAPY = 0.04; | |
| document.getElementById('morphoRateDisplay').innerText = (morphoAPY * 100).toFixed(2) + '%'; | |
| function estimateCompound(amount, apy, days) { | |
| const daily = apy / 365; | |
| const total = amount * Math.pow(1 + daily, days); | |
| return { total, gain: total - amount }; | |
| } | |
| document.getElementById('calcBtn').addEventListener('click', () => { | |
| const amt = parseFloat(document.getElementById('amount').value) || 0; | |
| const res = estimateCompound(amt, morphoAPY, 7); | |
| document.getElementById('gain7').innerText = res.gain.toFixed(6) + ' USDC'; | |
| document.getElementById('total7').innerText = res.total.toFixed(6) + ' USDC'; | |
| }); | |
| document.getElementById('setRateBtn').addEventListener('click', () => { | |
| const v = prompt('Nueva tasa APY (%) — ejemplo 4 para 4%:', (morphoAPY * 100).toString()); | |
| if (v === null) return; | |
| const n = parseFloat(v) / 100; | |
| if (!isNaN(n) && n >= 0) { | |
| morphoAPY = n; | |
| document.getElementById('morphoRateDisplay').innerText = (morphoAPY * 100).toFixed(2) + '%'; | |
| document.getElementById('calcBtn').click(); | |
| } else alert('Valor inválido'); | |
| }); | |
| (async function init() { | |
| const chartsDiv = document.getElementById('charts'); | |
| ASSETS.forEach(a => chartsDiv.appendChild(makeCard(a))); | |
| // Cargar histórico por activo (independiente) | |
| await Promise.allSettled(ASSETS.map(async (a) => { | |
| try { | |
| const series = await fetchMarketChart7d(a.id); | |
| const labels = series.map(p => tsToDateLabel(p.t)); | |
| const data = series.map(p => Number(p.price.toFixed(6))); | |
| createChart(a, labels, data); | |
| } catch (e) { | |
| showAssetError(a.id, `Error cargando histórico: ${String(e.message || e)}`); | |
| } | |
| })); | |
| // Primer cálculo Morpho | |
| document.getElementById('calcBtn').click(); | |
| // Primer refresh realtime (sin iniciar timer) | |
| try { await refreshLiveOnce(); } catch (e) { console.warn(e); } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |