972 lines
42 KiB
HTML
972 lines
42 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Wallet Timeline - 0x2956...7D1</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></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: 30px;
|
|
font-family: monospace;
|
|
font-size: 0.9rem;
|
|
}
|
|
.container {
|
|
max-width: 1800px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* 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 {
|
|
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: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 Chart */
|
|
.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 row */
|
|
.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 colors for tooltip */
|
|
.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; }
|
|
|
|
/* Flow paths */
|
|
.flow-path {
|
|
transition: opacity 0.2s, filter 0.2s;
|
|
cursor: pointer;
|
|
}
|
|
.flow-path:hover {
|
|
opacity: 1 !important;
|
|
filter: brightness(1.3);
|
|
}
|
|
|
|
/* Instructions */
|
|
.instructions {
|
|
text-align: center;
|
|
color: #666;
|
|
font-size: 0.8rem;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
/* River hover area */
|
|
.river-hover-area {
|
|
cursor: crosshair;
|
|
}
|
|
|
|
/* Balance indicator line */
|
|
.balance-indicator {
|
|
pointer-events: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>📈 Wallet Balance River</h1>
|
|
<p class="subtitle">0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1</p>
|
|
|
|
<!-- Stats -->
|
|
<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>
|
|
|
|
<!-- Controls -->
|
|
<div class="controls">
|
|
<div class="control-group">
|
|
<label>Filter Chain:</label>
|
|
<select id="chain-filter">
|
|
<option value="all">All Chains</option>
|
|
<option value="gnosis">Gnosis</option>
|
|
<option value="ethereum">Ethereum</option>
|
|
<option value="avalanche">Avalanche</option>
|
|
<option value="optimism">Optimism</option>
|
|
<option value="arbitrum">Arbitrum</option>
|
|
</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>
|
|
<option value="tec">TEC Only</option>
|
|
</select>
|
|
</div>
|
|
<div class="control-group">
|
|
<button id="reset-zoom">Reset View</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="instructions">🖱️ <strong>Scroll up/down to zoom</strong> • <strong>Scroll left/right (or Shift+scroll) to pan</strong> • <strong>Click and drag</strong> to pan • Hover for details</p>
|
|
|
|
<!-- Legend -->
|
|
<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>
|
|
|
|
<!-- Timeline Chart -->
|
|
<div class="timeline-section">
|
|
<div id="timeline-chart"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tooltip" id="tooltip" style="display: none;"></div>
|
|
|
|
<script>
|
|
// Transaction data with USD values
|
|
const transactions = [
|
|
// Gnosis Chain
|
|
{ date: '2023-03-22', type: 'in', amount: 1, token: 'TEC', usd: 0.5, chain: 'gnosis', from: '0x763d...87d4' },
|
|
{ date: '2023-03-28', type: 'in', amount: 17000, token: 'WXDAI', usd: 17000, chain: 'gnosis', from: '0x01d9...17bD' },
|
|
{ date: '2023-04-26', type: 'out', amount: 2306, token: 'WXDAI', usd: 2306, chain: 'gnosis', to: '0x9b55...0b4d' },
|
|
{ date: '2023-04-26', type: 'out', amount: 1050, token: 'WXDAI', usd: 1050, chain: 'gnosis', to: '0x763d...87d4' },
|
|
{ date: '2023-04-26', type: 'out', amount: 910, token: 'WXDAI', usd: 910, chain: 'gnosis', to: '0x1409...8fC' },
|
|
{ date: '2023-05-11', type: 'out', amount: 500, token: 'WXDAI', usd: 500, chain: 'gnosis', to: '0xb282...587b' },
|
|
{ date: '2023-06-07', type: 'out', amount: 8925, token: 'WXDAI', usd: 8925, chain: 'gnosis', to: 'Multiple recipients' },
|
|
{ date: '2023-07-05', type: 'in', amount: 3625, token: 'TEC', usd: 1812, chain: 'gnosis', from: '0x9b55...0b4d' },
|
|
{ date: '2023-09-10', type: 'out', amount: 3309, token: 'WXDAI', usd: 3309, chain: 'gnosis', to: '0x9b55...0b4d' },
|
|
{ date: '2023-10-04', type: 'in', amount: 631, token: 'WXDAI', usd: 631, chain: 'gnosis', from: '0x763d...87d4' },
|
|
{ date: '2023-10-04', type: 'out', amount: 1531, token: 'TEC', usd: 766, chain: 'gnosis', to: '0x763d...87d4' },
|
|
{ date: '2023-10-14', type: 'in', amount: 9710, token: 'TEC', usd: 4855, chain: 'gnosis', from: '0x5138...Ad00' },
|
|
{ date: '2023-10-18', type: 'in', amount: 2566, token: 'WXDAI', usd: 2566, chain: 'gnosis', from: '0x9b55...0b4d' },
|
|
{ date: '2023-10-18', type: 'out', amount: 5900, token: 'TEC', usd: 2950, chain: 'gnosis', to: '0x9b55...0b4d' },
|
|
{ date: '2023-10-26', type: 'out', amount: 2500, token: 'WXDAI', usd: 2500, chain: 'gnosis', to: '0x9b55...0b4d' },
|
|
{ date: '2023-11-01', type: 'out', amount: 736, token: 'WXDAI+TEC', usd: 618, chain: 'gnosis', to: 'Multiple recipients' },
|
|
{ date: '2023-12-15', type: 'out', amount: 5866, token: 'TEC+WXDAI', usd: 3130, chain: 'gnosis', to: '0x7785...3707' },
|
|
|
|
// Ethereum
|
|
{ date: '2024-04-09', type: 'in', amount: 10000, token: 'USDC', usd: 10000, chain: 'ethereum', from: '0xda1A...eE61' },
|
|
{ date: '2024-04-10', type: 'out', amount: 10000, token: 'USDC', usd: 10000, chain: 'ethereum', to: '0xB90B...6a98' },
|
|
{ date: '2024-05-08', type: 'in', amount: 10000, token: 'USDC', usd: 10000, chain: 'ethereum', from: '0xda1A...eE61' },
|
|
{ date: '2024-05-14', type: 'in', amount: 12500, token: 'USDC', usd: 12500, chain: 'ethereum', from: '0xA834...21F4' },
|
|
{ date: '2024-05-14', type: 'out', amount: 10000, token: 'USDC', usd: 10000, chain: 'ethereum', to: '0xB90B...6a98' },
|
|
{ date: '2024-06-12', type: 'in', amount: 10000, token: 'USDC', usd: 10000, chain: 'ethereum', from: '0xda1A...eE61' },
|
|
{ date: '2024-06-12', type: 'out', amount: 10000, token: 'USDC', usd: 10000, chain: 'ethereum', to: '0xB90B...6a98' },
|
|
{ date: '2024-06-28', type: 'in', amount: 6000, token: 'USDC', usd: 6000, chain: 'ethereum', from: '0xda1A...eE61' },
|
|
|
|
// Arbitrum
|
|
{ date: '2024-10-28', type: 'in', amount: 17, token: 'USDC', usd: 17, chain: 'arbitrum', from: '0x8e1b...a174' },
|
|
{ date: '2024-10-30', type: 'in', amount: 8, token: 'ARB', usd: 12, chain: 'arbitrum', from: '0x8e1b...a174' },
|
|
{ date: '2024-10-31', type: 'in', amount: 500, token: 'USDC', usd: 500, chain: 'arbitrum', from: '0x8e1b...a174' },
|
|
{ date: '2024-11-04', type: 'in', amount: 13, token: 'Mixed', usd: 20, chain: 'arbitrum', from: '0x8e1b...a174' },
|
|
{ date: '2024-11-06', type: 'in', amount: 5, token: 'Mixed', usd: 8, chain: 'arbitrum', from: '0x8e1b...a174' },
|
|
{ date: '2024-11-25', type: 'in', amount: 2601, token: 'USDC', usd: 2601, chain: 'arbitrum', from: '0xd2d9...6271' },
|
|
|
|
// Avalanche
|
|
{ date: '2025-03-04', type: 'in', amount: 0.007, token: 'AVAX', usd: 0.25, chain: 'avalanche', from: '0xc13f...' },
|
|
{ date: '2025-03-10', type: 'in', amount: 0.42, token: 'AVAX', usd: 15, chain: 'avalanche', from: '0xc13f...' },
|
|
{ date: '2025-03-17', type: 'in', amount: 12500, token: 'USDC', usd: 12500, chain: 'avalanche', from: '0x5129...Cd17' },
|
|
{ date: '2025-04-17', type: 'out', amount: 7140, token: 'USDC', usd: 7140, chain: 'avalanche', to: 'Multiple recipients' },
|
|
{ date: '2025-05-07', type: 'in', amount: 0.62, token: 'AVAX', usd: 22, chain: 'avalanche', from: '0xc13f...' },
|
|
{ date: '2025-05-13', type: 'out', amount: 3220, token: 'USDC+AVAX', usd: 6850, chain: 'avalanche', to: 'Multiple recipients' },
|
|
|
|
// Optimism
|
|
{ date: '2025-02-06', type: 'out', amount: 5740, token: 'DAI+USDC', usd: 5740, chain: 'optimism', to: 'Multiple recipients' },
|
|
|
|
// More recent
|
|
{ date: '2025-10-02', type: 'in', amount: 0.65, token: 'AVAX', usd: 23, chain: 'avalanche', from: '0xc13f...' },
|
|
{ date: '2025-11-29', type: 'in', amount: 3876, token: 'Yield-USD', usd: 3876, chain: 'ethereum', from: '0x1545...87d4' },
|
|
{ date: '2025-12-01', type: 'out', amount: 4620, token: 'USDC', usd: 4620, chain: 'ethereum', to: '0x763d...87d4' },
|
|
{ date: '2025-12-18', type: 'in', amount: 2537, token: 'USDC', usd: 2537, chain: 'avalanche', from: 'CoW Protocol' },
|
|
{ date: '2025-12-18', type: 'out', amount: 4677, token: 'USDC', usd: 4677, chain: 'avalanche', to: 'Multiple recipients' },
|
|
{ date: '2025-12-18', type: 'out', amount: 677, token: 'USDC', usd: 677, chain: 'arbitrum', to: '0x09b0...9822' },
|
|
{ date: '2025-12-19', type: 'out', amount: 6090, token: 'USDC', usd: 6090, chain: 'ethereum', to: '0x0acE...6b87e' },
|
|
{ date: '2026-01-22', type: 'out', amount: 5000, token: 'USDC', usd: 5000, chain: 'ethereum', to: '0xAbf5...7749' },
|
|
{ date: '2026-01-22', type: 'out', amount: 5000, token: 'USDC', usd: 5000, chain: 'arbitrum', to: '0x9425...a083' },
|
|
{ date: '2026-01-29', type: 'in', amount: 0.83, token: 'AVAX', usd: 30, chain: 'avalanche', from: '0x9a9E...' },
|
|
].map(tx => ({ ...tx, date: new Date(tx.date) })).sort((a, b) => a.date - b.date);
|
|
|
|
let currentFilter = { chain: 'all', token: 'all' };
|
|
let currentZoomTransform = d3.zoomIdentity;
|
|
|
|
function filterTransactions() {
|
|
return transactions.filter(tx => {
|
|
if (currentFilter.chain !== 'all' && tx.chain !== currentFilter.chain) return false;
|
|
if (currentFilter.token === 'usdc' && !['USDC', 'DAI', 'WXDAI'].some(t => tx.token.includes(t))) return false;
|
|
if (currentFilter.token === 'tec' && !tx.token.includes('TEC')) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function calculateStats(txs) {
|
|
let totalIn = 0, totalOut = 0, balance = 0, peak = 0;
|
|
|
|
txs.forEach(tx => {
|
|
if (tx.type === 'in') {
|
|
totalIn += tx.usd;
|
|
balance += tx.usd;
|
|
} else {
|
|
totalOut += tx.usd;
|
|
balance -= tx.usd;
|
|
}
|
|
if (balance > peak) peak = balance;
|
|
});
|
|
|
|
document.getElementById('total-inflow').textContent = '$' + Math.round(totalIn).toLocaleString();
|
|
document.getElementById('total-outflow').textContent = '$' + Math.round(totalOut).toLocaleString();
|
|
document.getElementById('net-change').textContent = '$' + Math.round(totalIn - totalOut).toLocaleString();
|
|
document.getElementById('peak-balance').textContent = '$' + Math.round(peak).toLocaleString();
|
|
document.getElementById('tx-count').textContent = txs.length;
|
|
}
|
|
|
|
// Store references for zoom updates
|
|
let svgElement, mainGroup, xScale, xAxisGroup, contentGroup;
|
|
let txs, balanceData, maxBalance, maxTx, balanceScale, flowScale;
|
|
let margin, width, height, centerY, timeExtent, timePadding;
|
|
|
|
function drawTimeline() {
|
|
const container = document.getElementById('timeline-chart');
|
|
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;
|
|
|
|
// Create outer SVG
|
|
svgElement = d3.select('#timeline-chart')
|
|
.append('svg')
|
|
.attr('width', width + margin.left + margin.right)
|
|
.attr('height', height + margin.top + margin.bottom)
|
|
.style('cursor', 'grab');
|
|
|
|
// Main group with margin
|
|
mainGroup = svgElement.append('g')
|
|
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
|
|
// Gradients (in defs, outside clip)
|
|
const defs = mainGroup.append('defs');
|
|
|
|
// Inflow gradient: green at top → cyan/blue at bottom (merging into river)
|
|
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);
|
|
|
|
// Outflow gradient: blue at top (leaving river) → red at bottom
|
|
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);
|
|
|
|
// River gradient (vertical for depth effect)
|
|
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);
|
|
|
|
// Add clip path for content
|
|
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);
|
|
|
|
// Time scale (base scale, will be transformed by zoom)
|
|
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]);
|
|
|
|
// Calculate running balance for river thickness (smooth transitions)
|
|
balanceData = [];
|
|
let runningBalance = 0;
|
|
|
|
// Add initial point
|
|
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
|
|
});
|
|
});
|
|
|
|
// Add final point
|
|
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;
|
|
|
|
// Scale for river thickness (balance) - make river more prominent
|
|
// Use linear scale for visual consistency with flows
|
|
balanceScale = d3.scaleLinear()
|
|
.domain([0, maxBalance])
|
|
.range([12, 100]);
|
|
|
|
// Scale for flow width - MUST match balanceScale for visual consistency
|
|
// A $5k flow should create the same width as a $5k change in river
|
|
flowScale = d3.scaleLinear()
|
|
.domain([0, maxBalance])
|
|
.range([0, 100 - 12]); // Match the balanceScale range difference
|
|
|
|
// Create content group with clip path (this will be transformed by zoom)
|
|
contentGroup = mainGroup.append('g')
|
|
.attr('clip-path', 'url(#chart-clip)')
|
|
.attr('class', 'content-group');
|
|
|
|
// X-axis group (below the clip, updates on zoom)
|
|
xAxisGroup = mainGroup.append('g')
|
|
.attr('class', 'x-axis')
|
|
.attr('transform', `translate(0, ${height + 20})`);
|
|
|
|
// Initial axis render
|
|
updateAxis(xScale);
|
|
|
|
// Draw all content with current scale
|
|
drawContent(xScale);
|
|
|
|
// Set up zoom behavior
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.5, 20]) // Min 0.5x, max 20x zoom
|
|
.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'));
|
|
|
|
// Custom wheel handler for horizontal scroll panning
|
|
svgElement.on('wheel', function(event) {
|
|
event.preventDefault();
|
|
|
|
const currentTransform = currentZoomTransform;
|
|
|
|
// Check if it's primarily horizontal scrolling (shift+scroll or trackpad horizontal)
|
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {
|
|
// Horizontal scroll = pan
|
|
const panAmount = event.deltaX !== 0 ? event.deltaX : event.deltaY;
|
|
const newTransform = currentTransform.translate(-panAmount * 0.5, 0);
|
|
svgElement.call(zoom.transform, newTransform);
|
|
} else {
|
|
// Vertical scroll = zoom (default behavior)
|
|
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
|
const [mouseX, mouseY] = d3.pointer(event);
|
|
|
|
// Zoom centered on mouse position
|
|
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);
|
|
|
|
// Apply any saved transform
|
|
if (currentZoomTransform !== d3.zoomIdentity) {
|
|
svgElement.call(zoom.transform, currentZoomTransform);
|
|
}
|
|
|
|
// Store zoom for reset button
|
|
window.currentZoom = zoom;
|
|
|
|
// Labels (outside clip, static position)
|
|
mainGroup.append('text')
|
|
.attr('class', 'flow-label')
|
|
.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('class', 'flow-label')
|
|
.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);
|
|
|
|
// Update axis with appropriate tick granularity
|
|
updateAxis(newXScale);
|
|
|
|
// Redraw content with new scale
|
|
drawContent(newXScale);
|
|
}
|
|
|
|
function updateAxis(scale) {
|
|
// Determine tick interval based on visible time range
|
|
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) {
|
|
// Clear previous content
|
|
contentGroup.selectAll('*').remove();
|
|
|
|
// Use smooth curve for organic river shape
|
|
const smoothCurve = d3.curveBasis;
|
|
|
|
// River outer glow/shadow
|
|
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 with smooth organic edges
|
|
const riverArea = d3.area()
|
|
.x(d => scale(d.date))
|
|
.y0(d => centerY + balanceScale(d.balance) / 2)
|
|
.y1(d => centerY - balanceScale(d.balance) / 2)
|
|
.curve(smoothCurve);
|
|
|
|
contentGroup.append('path')
|
|
.datum(balanceData)
|
|
.attr('fill', 'url(#riverGradient)')
|
|
.attr('d', riverArea);
|
|
|
|
// River edge highlights for depth
|
|
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)
|
|
);
|
|
|
|
// Draw flows - SHORT height, focus on WIDTH for proportion
|
|
// Flows touch the river and match the thickness change they create
|
|
const flowHeight = 60; // Fixed short height for all flows
|
|
|
|
let prevBalance = 0;
|
|
txs.forEach((tx, i) => {
|
|
const x = scale(tx.date);
|
|
// Flow width matches the river thickness change this transaction creates
|
|
const flowWidth = flowScale(tx.usd);
|
|
const halfWidth = Math.max(flowWidth / 2, 3); // Minimum 3px half-width for visibility
|
|
|
|
// Calculate river thickness BEFORE and AFTER this transaction
|
|
const balanceBefore = prevBalance;
|
|
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') {
|
|
// INFLOW: Short flow from above, touching the river top
|
|
const endY = riverTopAfter; // Flow ends exactly at river edge
|
|
const startY = endY - flowHeight; // Short fixed height
|
|
|
|
// Smooth curved flow using quadratic bezier for organic look
|
|
const path = d3.path();
|
|
const midY = startY + flowHeight * 0.5;
|
|
|
|
// Left edge curves inward then meets river
|
|
path.moveTo(x - halfWidth * 0.6, startY);
|
|
path.quadraticCurveTo(
|
|
x - halfWidth * 1.1, midY,
|
|
x - halfWidth, endY
|
|
);
|
|
|
|
// Bottom edge along river
|
|
path.lineTo(x + halfWidth, endY);
|
|
|
|
// Right edge curves back up
|
|
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 {
|
|
// OUTFLOW: Short flow going below, starting from river bottom
|
|
const startY = riverBottomAfter; // Flow starts exactly at river edge
|
|
const endY = startY + flowHeight; // Short fixed height
|
|
|
|
// Smooth curved flow
|
|
const path = d3.path();
|
|
const midY = startY + flowHeight * 0.5;
|
|
|
|
// Left edge from river, curves outward then down
|
|
path.moveTo(x - halfWidth, startY);
|
|
path.quadraticCurveTo(
|
|
x - halfWidth * 1.1, midY,
|
|
x - halfWidth * 0.6, endY
|
|
);
|
|
|
|
// Bottom edge
|
|
path.lineTo(x + halfWidth * 0.6, endY);
|
|
|
|
// Right edge curves back to river
|
|
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);
|
|
}
|
|
});
|
|
|
|
// Add invisible overlay for river hover - shows balance at any point
|
|
const riverHoverGroup = contentGroup.append('g').attr('class', 'river-hover-group');
|
|
|
|
// Create invisible wide rectangle over the entire river area for hover detection
|
|
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);
|
|
|
|
// Find the balance at this point in time
|
|
let balanceAtPoint = 0;
|
|
let lastTxBeforeHover = null;
|
|
|
|
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;
|
|
}
|
|
lastTxBeforeHover = txs[i];
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Show balance indicator line
|
|
riverHoverGroup.selectAll('.balance-line').remove();
|
|
riverHoverGroup.selectAll('.balance-dot').remove();
|
|
|
|
const riverThicknessAtPoint = balanceScale(Math.max(0, balanceAtPoint));
|
|
|
|
// Vertical indicator line
|
|
riverHoverGroup.append('line')
|
|
.attr('class', 'balance-line balance-indicator')
|
|
.attr('x1', mouseX)
|
|
.attr('x2', mouseX)
|
|
.attr('y1', centerY - riverThicknessAtPoint / 2 - 5)
|
|
.attr('y2', centerY + riverThicknessAtPoint / 2 + 5)
|
|
.attr('stroke', '#fff')
|
|
.attr('stroke-width', 2)
|
|
.attr('stroke-dasharray', '4,2')
|
|
.attr('opacity', 0.8);
|
|
|
|
// Dot at center
|
|
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);
|
|
|
|
// Show tooltip with balance
|
|
showRiverTooltip(event, hoveredDate, balanceAtPoint);
|
|
})
|
|
.on('mouseout', function() {
|
|
riverHoverGroup.selectAll('.balance-line').remove();
|
|
riverHoverGroup.selectAll('.balance-dot').remove();
|
|
hideTooltip();
|
|
});
|
|
}
|
|
|
|
function showTooltip(event, tx, balanceAfter) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
const chainBadge = `<span class="chain-badge ${tx.chain}">${tx.chain.charAt(0).toUpperCase() + tx.chain.slice(1)}</span>`;
|
|
|
|
tooltip.innerHTML = `
|
|
<div class="date">${tx.date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
|
${chainBadge}
|
|
<span class="amount ${tx.type === 'in' ? 'inflow' : 'outflow'}">
|
|
${tx.type === 'in' ? '+' : '-'}$${tx.usd.toLocaleString()}
|
|
</span>
|
|
<div class="token">${tx.amount.toLocaleString()} ${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');
|
|
const tooltipWidth = tooltip.offsetWidth;
|
|
const tooltipHeight = tooltip.offsetHeight;
|
|
|
|
let x = event.pageX + 15;
|
|
let y = event.pageY - 10;
|
|
|
|
// Keep tooltip on screen
|
|
if (x + tooltipWidth > window.innerWidth - 20) {
|
|
x = event.pageX - tooltipWidth - 15;
|
|
}
|
|
if (y + tooltipHeight > window.innerHeight - 20) {
|
|
y = event.pageY - tooltipHeight - 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 balance" 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);
|
|
}
|
|
|
|
// Event listeners
|
|
document.getElementById('chain-filter').addEventListener('change', (e) => {
|
|
currentFilter.chain = e.target.value;
|
|
currentZoomTransform = d3.zoomIdentity; // Reset zoom on filter change
|
|
drawTimeline();
|
|
});
|
|
|
|
document.getElementById('token-filter').addEventListener('change', (e) => {
|
|
currentFilter.token = e.target.value;
|
|
currentZoomTransform = d3.zoomIdentity; // Reset zoom on filter change
|
|
drawTimeline();
|
|
});
|
|
|
|
document.getElementById('reset-zoom').addEventListener('click', () => {
|
|
currentZoomTransform = d3.zoomIdentity;
|
|
if (svgElement && window.currentZoom) {
|
|
svgElement.transition().duration(500).call(window.currentZoom.transform, d3.zoomIdentity);
|
|
}
|
|
});
|
|
|
|
// Initial draw
|
|
drawTimeline();
|
|
|
|
// Debounced resize handler
|
|
let resizeTimeout;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
currentZoomTransform = d3.zoomIdentity;
|
|
drawTimeline();
|
|
}, 250);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|