579 lines
32 KiB
HTML
579 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Balance River | rWallet.online</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<script src="js/safe-api.js?v=4"></script>
|
|
<script src="js/data-transform.js?v=2"></script>
|
|
<script src="js/router.js?v=5"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
|
|
color: #e0e0e0;
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
h1 { text-align: center; margin-bottom: 10px; color: #00d4ff; font-size: 1.8rem; }
|
|
.subtitle { text-align: center; color: #888; margin-bottom: 20px; font-family: monospace; font-size: 0.9rem; }
|
|
.container { max-width: 1800px; margin: 0 auto; }
|
|
|
|
/* Address Bar */
|
|
.address-bar { margin-bottom: 24px; }
|
|
.address-bar-inner {
|
|
display: flex; gap: 10px; align-items: center; max-width: 700px; margin: 0 auto;
|
|
}
|
|
.back-link {
|
|
color: #00d4ff; text-decoration: none; font-weight: 600; font-size: 0.9rem;
|
|
display: flex; align-items: center; gap: 4px; white-space: nowrap;
|
|
padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(0,212,255,0.3);
|
|
background: rgba(0,212,255,0.05); transition: all 0.2s;
|
|
}
|
|
.back-link:hover { background: rgba(0,212,255,0.1); }
|
|
.back-icon { font-size: 1.1rem; }
|
|
#wallet-input {
|
|
flex: 1; padding: 10px 16px; border-radius: 8px;
|
|
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.05);
|
|
color: #e0e0e0; font-family: monospace; font-size: 0.9rem; outline: none;
|
|
transition: border-color 0.3s;
|
|
}
|
|
#wallet-input:focus { border-color: #00d4ff; }
|
|
#load-wallet-btn {
|
|
padding: 10px 20px; border-radius: 8px; border: none;
|
|
background: #00d4ff; color: #000; font-weight: 600; cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
#load-wallet-btn:hover { background: #00b8d9; }
|
|
|
|
/* Controls */
|
|
.controls {
|
|
display: flex; justify-content: center; gap: 16px; margin-bottom: 24px;
|
|
flex-wrap: wrap; align-items: center;
|
|
}
|
|
.control-group { display: flex; align-items: center; gap: 8px; }
|
|
.control-group label { font-size: 0.85rem; color: #888; }
|
|
select, button.ctrl-btn {
|
|
padding: 8px 16px; border-radius: 8px;
|
|
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.05);
|
|
color: #e0e0e0; font-size: 0.85rem; cursor: pointer;
|
|
}
|
|
select:hover, button.ctrl-btn:hover { background: rgba(255,255,255,0.1); }
|
|
|
|
/* Legend */
|
|
.legend { display: flex; justify-content: center; gap: 40px; margin-bottom: 20px; font-size: 0.9rem; }
|
|
.legend-item { display: flex; align-items: center; gap: 10px; }
|
|
.legend-flow { width: 50px; height: 20px; border-radius: 10px; }
|
|
.legend-flow.inflow { background: linear-gradient(180deg, #4ade80 0%, #22d3ee 50%, #00d4ff 100%); }
|
|
.legend-flow.outflow { background: linear-gradient(180deg, #00d4ff 0%, #f472b6 50%, #f87171 100%); }
|
|
.legend-flow.balance { background: linear-gradient(180deg, #00d4ff 0%, #0891b2 50%, #00d4ff 100%); border: 1px solid rgba(0,212,255,0.3); }
|
|
|
|
/* Timeline */
|
|
.timeline-section {
|
|
background: rgba(255,255,255,0.02); border-radius: 16px; padding: 24px;
|
|
margin-bottom: 24px; border: 1px solid rgba(255,255,255,0.1); overflow-x: auto;
|
|
}
|
|
#timeline-chart { width: 100%; min-height: 600px; }
|
|
|
|
/* Tooltip */
|
|
.tooltip {
|
|
position: absolute; background: rgba(10, 10, 20, 0.98);
|
|
border: 1px solid rgba(255,255,255,0.2); border-radius: 10px;
|
|
padding: 14px 18px; font-size: 0.85rem; pointer-events: none;
|
|
z-index: 1000; max-width: 320px; box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
}
|
|
.tooltip .date { color: #888; font-size: 0.75rem; margin-bottom: 6px; }
|
|
.tooltip .amount { font-weight: bold; font-size: 1.3rem; display: block; margin-bottom: 4px; }
|
|
.tooltip .amount.inflow { color: #4ade80; }
|
|
.tooltip .amount.outflow { color: #f87171; }
|
|
.tooltip .token { color: #00d4ff; font-size: 0.9rem; }
|
|
.tooltip .address { font-family: monospace; font-size: 0.75rem; color: #888; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1); }
|
|
.tooltip .chain-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; margin-bottom: 8px; }
|
|
.tooltip .balance-info { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1); font-size: 0.8rem; color: #00d4ff; }
|
|
|
|
/* Stats */
|
|
.stats-row {
|
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 12px; margin-bottom: 24px;
|
|
}
|
|
.stat-card {
|
|
background: rgba(255,255,255,0.03); border-radius: 10px; padding: 14px;
|
|
border: 1px solid rgba(255,255,255,0.1); text-align: center;
|
|
}
|
|
.stat-card h4 { color: #888; font-size: 0.65rem; text-transform: uppercase; margin-bottom: 6px; }
|
|
.stat-card .value { font-size: 1.2rem; font-weight: bold; }
|
|
.stat-card .value.inflow { color: #4ade80; }
|
|
.stat-card .value.outflow { color: #f87171; }
|
|
.stat-card .value.balance { color: #00d4ff; }
|
|
|
|
.chain-badge.gnosis { background: #04795b; }
|
|
.chain-badge.ethereum { background: #627eea; }
|
|
.chain-badge.avalanche { background: #e84142; }
|
|
.chain-badge.optimism { background: #ff0420; }
|
|
.chain-badge.arbitrum { background: #28a0f0; }
|
|
.chain-badge.polygon { background: #8247e5; }
|
|
.chain-badge.base { background: #0052ff; }
|
|
|
|
.flow-path { transition: opacity 0.2s, filter 0.2s; cursor: pointer; }
|
|
.flow-path:hover { opacity: 1 !important; filter: brightness(1.3); }
|
|
|
|
.instructions { text-align: center; color: #666; font-size: 0.8rem; margin-bottom: 16px; }
|
|
.river-hover-area { cursor: crosshair; }
|
|
.balance-indicator { pointer-events: none; }
|
|
|
|
/* Loading */
|
|
.loading { text-align: center; padding: 80px 20px; color: #888; }
|
|
.loading .spinner {
|
|
width: 40px; height: 40px; border: 3px solid rgba(0,212,255,0.2);
|
|
border-top-color: #00d4ff; border-radius: 50%;
|
|
animation: spin 0.8s linear infinite; margin: 0 auto 16px;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.error { text-align: center; padding: 60px 20px; color: #f87171; }
|
|
.empty { text-align: center; padding: 60px 20px; color: #666; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Wallet Balance River</h1>
|
|
<p class="subtitle" id="wallet-subtitle">Enter a Safe wallet address to visualize</p>
|
|
|
|
<div id="address-bar-container"></div>
|
|
|
|
<div id="content">
|
|
<div class="empty">
|
|
<p style="font-size:1.2rem; margin-bottom:8px;">Enter a Safe wallet address above to get started</p>
|
|
<p>Or try the demo: <a href="?address=0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" style="color:#00d4ff;">TEC Commons Fund</a></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tooltip" id="tooltip" style="display: none;"></div>
|
|
|
|
<script>
|
|
Router.createAddressBar('address-bar-container');
|
|
|
|
let transactions = [];
|
|
let currentFilter = { chain: 'all', token: 'all' };
|
|
let currentZoomTransform = d3.zoomIdentity;
|
|
let svgElement, mainGroup, xScale, xAxisGroup, contentGroup;
|
|
let txs, balanceData, maxBalance, maxTx, balanceScale, flowScale;
|
|
let margin, width, height, centerY, timeExtent, timePadding;
|
|
|
|
async function loadWallet(address) {
|
|
const content = document.getElementById('content');
|
|
const subtitle = document.getElementById('wallet-subtitle');
|
|
subtitle.textContent = address;
|
|
|
|
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Detecting Safe wallets across chains...</p></div>';
|
|
|
|
try {
|
|
const detected = await SafeAPI.detectSafeChains(address);
|
|
if (detected.length === 0) {
|
|
content.innerHTML = '<div class="error"><p style="font-size:1.2rem;">No Safe wallet found at this address</p><p style="color:#888;margin-top:8px;">This tool works with Safe (Gnosis Safe) multi-sig wallets.</p></div>';
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Fetching transaction data across ' + detected.length + ' chain(s)...</p></div>';
|
|
|
|
const chainDataMap = await SafeAPI.fetchAllChainsData(address, detected);
|
|
|
|
// Transform to timeline format
|
|
transactions = DataTransform.transformToTimelineData(chainDataMap, address);
|
|
|
|
if (transactions.length === 0) {
|
|
content.innerHTML = '<div class="empty"><p>No transactions found for this wallet</p></div>';
|
|
return;
|
|
}
|
|
|
|
// Build chain filter options from actual data
|
|
const chainsInData = [...new Set(transactions.map(t => t.chain))];
|
|
|
|
// Render the visualization UI
|
|
content.innerHTML = `
|
|
<div class="stats-row">
|
|
<div class="stat-card"><h4>Total Inflow</h4><div class="value inflow" id="total-inflow">$0</div></div>
|
|
<div class="stat-card"><h4>Total Outflow</h4><div class="value outflow" id="total-outflow">$0</div></div>
|
|
<div class="stat-card"><h4>Net Change</h4><div class="value balance" id="net-change">$0</div></div>
|
|
<div class="stat-card"><h4>Peak Balance</h4><div class="value balance" id="peak-balance">$0</div></div>
|
|
<div class="stat-card"><h4>Transactions</h4><div class="value" id="tx-count">0</div></div>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<div class="control-group">
|
|
<label>Filter Chain:</label>
|
|
<select id="chain-filter">
|
|
<option value="all">All Chains</option>
|
|
${chainsInData.map(c => `<option value="${c}">${c.charAt(0).toUpperCase() + c.slice(1)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="control-group">
|
|
<label>Filter Token:</label>
|
|
<select id="token-filter">
|
|
<option value="all">All Tokens</option>
|
|
<option value="usdc">Stablecoins Only</option>
|
|
</select>
|
|
</div>
|
|
<div class="control-group">
|
|
<button class="ctrl-btn" id="reset-zoom">Reset View</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="instructions">Scroll up/down to zoom | Scroll left/right (or Shift+scroll) to pan | Click and drag to pan | Hover for details</p>
|
|
|
|
<div class="legend">
|
|
<div class="legend-item"><div class="legend-flow inflow"></div><span>Inflows (green -> blue)</span></div>
|
|
<div class="legend-item"><div class="legend-flow balance"></div><span>Balance River</span></div>
|
|
<div class="legend-item"><div class="legend-flow outflow"></div><span>Outflows (blue -> red)</span></div>
|
|
</div>
|
|
|
|
<div class="timeline-section"><div id="timeline-chart"></div></div>
|
|
`;
|
|
|
|
// Bind filter events
|
|
document.getElementById('chain-filter').addEventListener('change', e => {
|
|
currentFilter.chain = e.target.value;
|
|
currentZoomTransform = d3.zoomIdentity;
|
|
drawTimeline();
|
|
});
|
|
document.getElementById('token-filter').addEventListener('change', e => {
|
|
currentFilter.token = e.target.value;
|
|
currentZoomTransform = d3.zoomIdentity;
|
|
drawTimeline();
|
|
});
|
|
document.getElementById('reset-zoom').addEventListener('click', () => {
|
|
currentZoomTransform = d3.zoomIdentity;
|
|
if (svgElement && window.currentZoom) {
|
|
svgElement.transition().duration(500).call(window.currentZoom.transform, d3.zoomIdentity);
|
|
}
|
|
});
|
|
|
|
currentFilter = { chain: 'all', token: 'all' };
|
|
currentZoomTransform = d3.zoomIdentity;
|
|
drawTimeline();
|
|
|
|
} catch (err) {
|
|
content.innerHTML = `<div class="error"><p>Error: ${err.message}</p></div>`;
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
function filterTransactions() {
|
|
return transactions.filter(tx => {
|
|
if (currentFilter.chain !== 'all' && tx.chain !== currentFilter.chain) return false;
|
|
if (currentFilter.token === 'usdc' && !tx.hasUsdEstimate) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function calculateStats(txsList) {
|
|
let totalIn = 0, totalOut = 0, balance = 0, peak = 0;
|
|
txsList.forEach(tx => {
|
|
if (tx.type === 'in') { totalIn += tx.usd; balance += tx.usd; }
|
|
else { totalOut += tx.usd; balance -= tx.usd; }
|
|
if (balance > peak) peak = balance;
|
|
});
|
|
const el = id => document.getElementById(id);
|
|
if (el('total-inflow')) el('total-inflow').textContent = '$' + Math.round(totalIn).toLocaleString();
|
|
if (el('total-outflow')) el('total-outflow').textContent = '$' + Math.round(totalOut).toLocaleString();
|
|
if (el('net-change')) el('net-change').textContent = '$' + Math.round(totalIn - totalOut).toLocaleString();
|
|
if (el('peak-balance')) el('peak-balance').textContent = '$' + Math.round(peak).toLocaleString();
|
|
if (el('tx-count')) el('tx-count').textContent = txsList.length;
|
|
}
|
|
|
|
function drawTimeline() {
|
|
const container = document.getElementById('timeline-chart');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
|
|
txs = filterTransactions();
|
|
calculateStats(txs);
|
|
|
|
if (txs.length === 0) {
|
|
container.innerHTML = '<p style="text-align:center;color:#666;padding:100px;">No transactions match the current filter.</p>';
|
|
return;
|
|
}
|
|
|
|
margin = { top: 100, right: 50, bottom: 80, left: 60 };
|
|
width = Math.max(1200, container.clientWidth || 1200) - margin.left - margin.right;
|
|
height = 600 - margin.top - margin.bottom;
|
|
centerY = height / 2;
|
|
|
|
svgElement = d3.select('#timeline-chart')
|
|
.append('svg')
|
|
.attr('width', width + margin.left + margin.right)
|
|
.attr('height', height + margin.top + margin.bottom)
|
|
.style('cursor', 'grab');
|
|
|
|
mainGroup = svgElement.append('g')
|
|
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
|
|
const defs = mainGroup.append('defs');
|
|
|
|
const inflowGradient = defs.append('linearGradient').attr('id', 'inflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%');
|
|
inflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.3);
|
|
inflowGradient.append('stop').attr('offset', '30%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.8);
|
|
inflowGradient.append('stop').attr('offset', '60%').attr('stop-color', '#22d3ee').attr('stop-opacity', 0.9);
|
|
inflowGradient.append('stop').attr('offset', '85%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.95);
|
|
inflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#00d4ff').attr('stop-opacity', 1);
|
|
|
|
const outflowGradient = defs.append('linearGradient').attr('id', 'outflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%');
|
|
outflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff').attr('stop-opacity', 1);
|
|
outflowGradient.append('stop').attr('offset', '15%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.95);
|
|
outflowGradient.append('stop').attr('offset', '40%').attr('stop-color', '#f472b6').attr('stop-opacity', 0.9);
|
|
outflowGradient.append('stop').attr('offset', '70%').attr('stop-color', '#f87171').attr('stop-opacity', 0.8);
|
|
outflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#f87171').attr('stop-opacity', 0.3);
|
|
|
|
const riverGradient = defs.append('linearGradient').attr('id', 'riverGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%');
|
|
riverGradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.9);
|
|
riverGradient.append('stop').attr('offset', '30%').attr('stop-color', '#0891b2').attr('stop-opacity', 1);
|
|
riverGradient.append('stop').attr('offset', '50%').attr('stop-color', '#0e7490').attr('stop-opacity', 1);
|
|
riverGradient.append('stop').attr('offset', '70%').attr('stop-color', '#0891b2').attr('stop-opacity', 1);
|
|
riverGradient.append('stop').attr('offset', '100%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.9);
|
|
|
|
defs.append('clipPath').attr('id', 'chart-clip').append('rect')
|
|
.attr('x', 0).attr('y', -margin.top).attr('width', width).attr('height', height + margin.top + margin.bottom);
|
|
|
|
timeExtent = d3.extent(txs, d => d.date);
|
|
timePadding = (timeExtent[1] - timeExtent[0]) * 0.05;
|
|
xScale = d3.scaleTime()
|
|
.domain([new Date(timeExtent[0].getTime() - timePadding), new Date(timeExtent[1].getTime() + timePadding)])
|
|
.range([0, width]);
|
|
|
|
balanceData = [];
|
|
let runningBalance = 0;
|
|
balanceData.push({ date: new Date(timeExtent[0].getTime() - timePadding), balance: 0, tx: null });
|
|
txs.forEach(tx => {
|
|
if (tx.type === 'in') runningBalance += tx.usd;
|
|
else runningBalance -= tx.usd;
|
|
balanceData.push({ date: tx.date, balance: Math.max(0, runningBalance), tx: tx });
|
|
});
|
|
balanceData.push({ date: new Date(timeExtent[1].getTime() + timePadding), balance: Math.max(0, runningBalance), tx: null });
|
|
|
|
maxBalance = d3.max(balanceData, d => d.balance) || 1;
|
|
maxTx = d3.max(txs, d => d.usd) || 1;
|
|
|
|
balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([12, 100]);
|
|
flowScale = d3.scaleLinear().domain([0, maxBalance]).range([0, 100 - 12]);
|
|
|
|
contentGroup = mainGroup.append('g').attr('clip-path', 'url(#chart-clip)').attr('class', 'content-group');
|
|
|
|
xAxisGroup = mainGroup.append('g').attr('class', 'x-axis').attr('transform', `translate(0, ${height + 20})`);
|
|
|
|
updateAxis(xScale);
|
|
drawContent(xScale);
|
|
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.5, 20])
|
|
.translateExtent([[-width * 2, 0], [width * 3, height]])
|
|
.extent([[0, 0], [width, height]])
|
|
.on('zoom', zoomed)
|
|
.on('start', () => svgElement.style('cursor', 'grabbing'))
|
|
.on('end', () => svgElement.style('cursor', 'grab'));
|
|
|
|
svgElement.on('wheel', function(event) {
|
|
event.preventDefault();
|
|
const currentTransform = currentZoomTransform;
|
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {
|
|
const panAmount = event.deltaX !== 0 ? event.deltaX : event.deltaY;
|
|
const newTransform = currentTransform.translate(-panAmount * 0.5, 0);
|
|
svgElement.call(zoom.transform, newTransform);
|
|
} else {
|
|
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
|
const [mouseX] = d3.pointer(event);
|
|
const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor));
|
|
const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k);
|
|
const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale);
|
|
svgElement.call(zoom.transform, newTransform);
|
|
}
|
|
}, { passive: false });
|
|
|
|
svgElement.call(zoom);
|
|
if (currentZoomTransform !== d3.zoomIdentity) svgElement.call(zoom.transform, currentZoomTransform);
|
|
window.currentZoom = zoom;
|
|
|
|
mainGroup.append('text').attr('x', width / 2).attr('y', -60).attr('text-anchor', 'middle')
|
|
.attr('fill', '#4ade80').attr('font-size', '14px').attr('font-weight', 'bold').text('INFLOWS');
|
|
mainGroup.append('text').attr('x', width / 2).attr('y', height + 65).attr('text-anchor', 'middle')
|
|
.attr('fill', '#f87171').attr('font-size', '14px').attr('font-weight', 'bold').text('OUTFLOWS');
|
|
}
|
|
|
|
function zoomed(event) {
|
|
currentZoomTransform = event.transform;
|
|
const newXScale = event.transform.rescaleX(xScale);
|
|
updateAxis(newXScale);
|
|
drawContent(newXScale);
|
|
}
|
|
|
|
function updateAxis(scale) {
|
|
const domain = scale.domain();
|
|
const timeSpan = domain[1] - domain[0];
|
|
const days = timeSpan / (1000 * 60 * 60 * 24);
|
|
let tickInterval, tickFormat;
|
|
if (days < 14) { tickInterval = d3.timeDay.every(1); tickFormat = d3.timeFormat('%b %d'); }
|
|
else if (days < 60) { tickInterval = d3.timeWeek.every(1); tickFormat = d3.timeFormat('%b %d'); }
|
|
else if (days < 180) { tickInterval = d3.timeMonth.every(1); tickFormat = d3.timeFormat('%b %Y'); }
|
|
else if (days < 365) { tickInterval = d3.timeMonth.every(2); tickFormat = d3.timeFormat('%b %Y'); }
|
|
else { tickInterval = d3.timeMonth.every(3); tickFormat = d3.timeFormat('%b %Y'); }
|
|
|
|
const xAxis = d3.axisBottom(scale).ticks(tickInterval).tickFormat(tickFormat);
|
|
xAxisGroup.call(xAxis).selectAll('text').attr('fill', '#888').attr('font-size', '11px').attr('transform', 'rotate(-30)').attr('text-anchor', 'end');
|
|
xAxisGroup.selectAll('.domain, .tick line').attr('stroke', '#444');
|
|
}
|
|
|
|
function drawContent(scale) {
|
|
contentGroup.selectAll('*').remove();
|
|
const smoothCurve = d3.curveBasis;
|
|
|
|
// River glow
|
|
contentGroup.append('path').datum(balanceData).attr('fill', 'rgba(0, 212, 255, 0.08)')
|
|
.attr('d', d3.area().x(d => scale(d.date)).y0(d => centerY + balanceScale(d.balance) / 2 + 15).y1(d => centerY - balanceScale(d.balance) / 2 - 15).curve(smoothCurve));
|
|
|
|
// Main river
|
|
contentGroup.append('path').datum(balanceData).attr('fill', 'url(#riverGradient)')
|
|
.attr('d', d3.area().x(d => scale(d.date)).y0(d => centerY + balanceScale(d.balance) / 2).y1(d => centerY - balanceScale(d.balance) / 2).curve(smoothCurve));
|
|
|
|
// Edge highlights
|
|
contentGroup.append('path').datum(balanceData).attr('fill', 'none').attr('stroke', 'rgba(255,255,255,0.3)').attr('stroke-width', 1.5)
|
|
.attr('d', d3.line().x(d => scale(d.date)).y(d => centerY - balanceScale(d.balance) / 2).curve(smoothCurve));
|
|
contentGroup.append('path').datum(balanceData).attr('fill', 'none').attr('stroke', 'rgba(0,0,0,0.2)').attr('stroke-width', 1)
|
|
.attr('d', d3.line().x(d => scale(d.date)).y(d => centerY + balanceScale(d.balance) / 2).curve(smoothCurve));
|
|
|
|
// Flows
|
|
const flowHeight = 60;
|
|
let prevBalance = 0;
|
|
|
|
txs.forEach(tx => {
|
|
const x = scale(tx.date);
|
|
const flowWidth = flowScale(tx.usd);
|
|
const halfWidth = Math.max(flowWidth / 2, 3);
|
|
|
|
if (tx.type === 'in') prevBalance += tx.usd;
|
|
else prevBalance -= tx.usd;
|
|
const balanceAfter = Math.max(0, prevBalance);
|
|
const riverTopAfter = centerY - balanceScale(balanceAfter) / 2;
|
|
const riverBottomAfter = centerY + balanceScale(balanceAfter) / 2;
|
|
|
|
if (tx.type === 'in') {
|
|
const endY = riverTopAfter;
|
|
const startY = endY - flowHeight;
|
|
const midY = startY + flowHeight * 0.5;
|
|
const path = d3.path();
|
|
path.moveTo(x - halfWidth * 0.6, startY);
|
|
path.quadraticCurveTo(x - halfWidth * 1.1, midY, x - halfWidth, endY);
|
|
path.lineTo(x + halfWidth, endY);
|
|
path.quadraticCurveTo(x + halfWidth * 1.1, midY, x + halfWidth * 0.6, startY);
|
|
path.closePath();
|
|
contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#inflowGradient)')
|
|
.attr('class', 'flow-path').attr('opacity', 0.9)
|
|
.on('mouseover', event => showTooltip(event, tx, prevBalance))
|
|
.on('mousemove', event => moveTooltip(event))
|
|
.on('mouseout', hideTooltip);
|
|
} else {
|
|
const startY = riverBottomAfter;
|
|
const endY = startY + flowHeight;
|
|
const midY = startY + flowHeight * 0.5;
|
|
const path = d3.path();
|
|
path.moveTo(x - halfWidth, startY);
|
|
path.quadraticCurveTo(x - halfWidth * 1.1, midY, x - halfWidth * 0.6, endY);
|
|
path.lineTo(x + halfWidth * 0.6, endY);
|
|
path.quadraticCurveTo(x + halfWidth * 1.1, midY, x + halfWidth, startY);
|
|
path.closePath();
|
|
contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#outflowGradient)')
|
|
.attr('class', 'flow-path').attr('opacity', 0.9)
|
|
.on('mouseover', event => showTooltip(event, tx, prevBalance))
|
|
.on('mousemove', event => moveTooltip(event))
|
|
.on('mouseout', hideTooltip);
|
|
}
|
|
});
|
|
|
|
// River hover
|
|
const riverHoverGroup = contentGroup.append('g');
|
|
riverHoverGroup.append('rect').attr('class', 'river-hover-area')
|
|
.attr('x', scale.range()[0] - 50).attr('y', centerY - 100)
|
|
.attr('width', scale.range()[1] - scale.range()[0] + 100).attr('height', 200)
|
|
.attr('fill', 'transparent')
|
|
.on('mousemove', function(event) {
|
|
const [mouseX] = d3.pointer(event);
|
|
const hoveredDate = scale.invert(mouseX);
|
|
let balanceAtPoint = 0;
|
|
for (let i = 0; i < txs.length; i++) {
|
|
if (txs[i].date <= hoveredDate) {
|
|
if (txs[i].type === 'in') balanceAtPoint += txs[i].usd;
|
|
else balanceAtPoint -= txs[i].usd;
|
|
} else break;
|
|
}
|
|
riverHoverGroup.selectAll('.balance-line,.balance-dot').remove();
|
|
const t = balanceScale(Math.max(0, balanceAtPoint));
|
|
riverHoverGroup.append('line').attr('class', 'balance-line balance-indicator')
|
|
.attr('x1', mouseX).attr('x2', mouseX).attr('y1', centerY - t / 2 - 5).attr('y2', centerY + t / 2 + 5)
|
|
.attr('stroke', '#fff').attr('stroke-width', 2).attr('stroke-dasharray', '4,2').attr('opacity', 0.8);
|
|
riverHoverGroup.append('circle').attr('class', 'balance-dot balance-indicator')
|
|
.attr('cx', mouseX).attr('cy', centerY).attr('r', 5)
|
|
.attr('fill', '#00d4ff').attr('stroke', '#fff').attr('stroke-width', 2);
|
|
showRiverTooltip(event, hoveredDate, balanceAtPoint);
|
|
})
|
|
.on('mouseout', function() {
|
|
riverHoverGroup.selectAll('.balance-line,.balance-dot').remove();
|
|
hideTooltip();
|
|
});
|
|
}
|
|
|
|
function showTooltip(event, tx, balanceAfter) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
const chainClass = tx.chain || '';
|
|
tooltip.innerHTML = `
|
|
<div class="date">${tx.date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
|
<span class="chain-badge ${chainClass}">${chainClass.charAt(0).toUpperCase() + chainClass.slice(1)}</span>
|
|
<span class="amount ${tx.type === 'in' ? 'inflow' : 'outflow'}">
|
|
${tx.type === 'in' ? '+' : '-'}$${Math.round(tx.usd).toLocaleString()}
|
|
</span>
|
|
<div class="token">${tx.amount.toLocaleString(undefined, {maximumFractionDigits: 4})} ${tx.token}</div>
|
|
<div class="address">${tx.type === 'in' ? 'From: ' + (tx.from || 'Unknown') : 'To: ' + (tx.to || 'Unknown')}</div>
|
|
<div class="balance-info">Balance after: $${Math.round(Math.max(0, balanceAfter)).toLocaleString()}</div>
|
|
`;
|
|
tooltip.style.display = 'block';
|
|
moveTooltip(event);
|
|
}
|
|
|
|
function moveTooltip(event) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
let x = event.pageX + 15, y = event.pageY - 10;
|
|
if (x + tooltip.offsetWidth > window.innerWidth - 20) x = event.pageX - tooltip.offsetWidth - 15;
|
|
if (y + tooltip.offsetHeight > window.innerHeight - 20) y = event.pageY - tooltip.offsetHeight - 10;
|
|
tooltip.style.left = x + 'px';
|
|
tooltip.style.top = y + 'px';
|
|
}
|
|
|
|
function hideTooltip() { document.getElementById('tooltip').style.display = 'none'; }
|
|
|
|
function showRiverTooltip(event, date, balance) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
tooltip.innerHTML = `
|
|
<div class="date">${date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
|
<span class="amount" style="color: #00d4ff;">$${Math.round(Math.max(0, balance)).toLocaleString()}</span>
|
|
<div style="margin-top: 8px; font-size: 0.8rem; color: #888;">Wallet balance at this point in time</div>
|
|
`;
|
|
tooltip.style.display = 'block';
|
|
moveTooltip(event);
|
|
}
|
|
|
|
// Listen for wallet changes
|
|
window.addEventListener('wallet-changed', e => loadWallet(e.detail.address));
|
|
|
|
// Resize handler
|
|
let resizeTimeout;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => { currentZoomTransform = d3.zoomIdentity; drawTimeline(); }, 250);
|
|
});
|
|
|
|
// Auto-load from URL
|
|
const { address } = Router.getParams();
|
|
if (address && Router.isValidAddress(address)) loadWallet(address);
|
|
</script>
|
|
</body>
|
|
</html>
|