feat(rwallet): timeline-first UX overhaul with proportional rivers
- Fix USD estimation: add NATIVE_APPROX_USD price table for ETH, MATIC, BNB, AVAX, xDAI, CELO, GNO; unknown tokens fall back to $0 instead of raw token amounts (fixes wildly wrong river widths) - Fix scroll hijacking: only intercept Ctrl+wheel (pinch-to-zoom) on timeline, flow chart, and sankey; normal two-finger scroll passes through - Collapse address bar to compact chip after wallet loads with Save/Change - Promote watchlist as horizontal chip selector above dashboard; merge example wallets as dashed "suggested" chips when watchlist is empty - Default to timeline view after wallet detection (auto-loads transfers) - Move balance/transaction tables to Details modal (pill button, overlay with backdrop blur) — stats cards hidden in viz views since D3 shows them Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1282ba5325
commit
51be476694
|
|
@ -182,6 +182,10 @@ class FolkWalletViewer extends HTMLElement {
|
|||
private sandboxDays = 365;
|
||||
private sandboxComparisons: ProtocolComparison[] = [];
|
||||
|
||||
// UX state
|
||||
private addressBarExpanded = false;
|
||||
private detailsModalOpen = false;
|
||||
|
||||
// Visualization state
|
||||
private activeView: ViewTab = "balances";
|
||||
private transfers: Map<string, any> | null = null;
|
||||
|
|
@ -609,7 +613,6 @@ class FolkWalletViewer extends HTMLElement {
|
|||
this.allChainBalances = new Map();
|
||||
this.chainFilter = null;
|
||||
this.walletType = "";
|
||||
this.activeView = "balances";
|
||||
this.transfers = null;
|
||||
this.vizData = {};
|
||||
this.render();
|
||||
|
|
@ -642,6 +645,19 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
this.loading = false;
|
||||
this.addressBarExpanded = false;
|
||||
|
||||
// Default to timeline view and auto-load transfers when wallet data is available
|
||||
if (this.hasData()) {
|
||||
const showViz = this.walletType === "safe" || this.isDemo || (this.isAuthenticated && this.crdtBalances.length > 0);
|
||||
if (showViz) {
|
||||
this.activeView = "timeline";
|
||||
this.render();
|
||||
this.loadTransfers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -997,6 +1013,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
||||
if (input) {
|
||||
this.address = input.value.trim();
|
||||
this.addressBarExpanded = false;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("address", this.address);
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
|
|
@ -1947,6 +1964,85 @@ class FolkWalletViewer extends HTMLElement {
|
|||
.sandbox-balance { display: none; }
|
||||
}
|
||||
|
||||
/* ── Compact address bar ── */
|
||||
.compact-address-bar {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
|
||||
max-width: 640px; margin-left: auto; margin-right: auto;
|
||||
padding: 8px 14px; border-radius: 10px;
|
||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
|
||||
}
|
||||
.compact-addr-chip {
|
||||
display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0;
|
||||
font-family: monospace; font-size: 14px; font-weight: 600; color: var(--rs-text-primary);
|
||||
}
|
||||
.compact-btn {
|
||||
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--rs-border);
|
||||
background: transparent; color: var(--rs-text-secondary); cursor: pointer;
|
||||
font-size: 12px; font-weight: 500; transition: all 0.2s; white-space: nowrap;
|
||||
}
|
||||
.compact-btn:hover { border-color: var(--rs-accent); color: var(--rs-accent); }
|
||||
|
||||
/* ── Watchlist chips ── */
|
||||
.watchlist-bar {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||
margin-bottom: 16px; max-width: 640px; margin-left: auto; margin-right: auto;
|
||||
}
|
||||
.watchlist-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; border-radius: 16px;
|
||||
border: 1px solid var(--rs-border-subtle); background: var(--rs-bg-surface);
|
||||
color: var(--rs-text-secondary); cursor: pointer; font-size: 12px;
|
||||
transition: all 0.2s; white-space: nowrap;
|
||||
}
|
||||
.watchlist-chip:hover { border-color: var(--rs-accent); color: var(--rs-accent); }
|
||||
.watchlist-chip.active { border-color: var(--rs-accent); background: rgba(20,184,166,0.1); color: var(--rs-accent); }
|
||||
.watchlist-chip.suggested { border-style: dashed; color: var(--rs-text-muted); }
|
||||
.watchlist-chip.suggested:hover { border-style: solid; }
|
||||
.watchlist-chip.add-chip { padding: 4px 8px; font-size: 14px; font-weight: 700; }
|
||||
.chip-label { font-weight: 500; }
|
||||
.chip-addr { font-family: monospace; font-size: 10px; color: var(--rs-text-muted); }
|
||||
.chip-remove {
|
||||
font-size: 14px; line-height: 1; padding: 0 2px; margin-left: 2px;
|
||||
color: var(--rs-text-muted); cursor: pointer;
|
||||
}
|
||||
.chip-remove:hover { color: var(--rs-error); }
|
||||
|
||||
/* ── Details modal ── */
|
||||
.details-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.details-modal {
|
||||
background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border-subtle);
|
||||
border-radius: 16px; max-width: 900px; width: 100%;
|
||||
max-height: 80vh; display: flex; flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.details-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--rs-border-subtle);
|
||||
}
|
||||
.details-modal-header h3 { margin: 0; font-size: 16px; color: var(--rs-text-primary); }
|
||||
.details-modal-header button {
|
||||
background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
|
||||
font-size: 20px; padding: 4px 8px; border-radius: 6px;
|
||||
}
|
||||
.details-modal-header button:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
|
||||
.details-modal-body { overflow-y: auto; padding: 20px; }
|
||||
|
||||
/* ── Viz wrapper with details button ── */
|
||||
.viz-wrapper { position: relative; }
|
||||
.details-btn {
|
||||
position: absolute; top: 8px; right: 8px; z-index: 5;
|
||||
padding: 5px 14px; border-radius: 16px;
|
||||
border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
|
||||
color: var(--rs-text-secondary); cursor: pointer; font-size: 12px;
|
||||
font-weight: 500; transition: all 0.2s; backdrop-filter: blur(4px);
|
||||
}
|
||||
.details-btn:hover { border-color: var(--rs-accent); color: var(--rs-accent); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-title { font-size: 22px; }
|
||||
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
|
@ -1959,6 +2055,8 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
@media (max-width: 640px) {
|
||||
.view-tab { padding: 6px 10px; font-size: 12px; }
|
||||
.compact-address-bar { flex-wrap: wrap; }
|
||||
.watchlist-bar { gap: 4px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.features { grid-template-columns: 1fr; }
|
||||
|
|
@ -2813,6 +2911,8 @@ class FolkWalletViewer extends HTMLElement {
|
|||
Local
|
||||
</div>` : "";
|
||||
|
||||
const isVizView = this.activeView !== "balances" && this.activeView !== "yield";
|
||||
|
||||
return `
|
||||
<div class="chains">
|
||||
<div class="chain-btn ${this.chainFilter === null ? "active" : ""}"
|
||||
|
|
@ -2823,7 +2923,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
${localBtn}
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
${!isVizView ? `<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Value</div>
|
||||
<div class="stat-value">${this.formatUSD(String(totalUSD))}</div>
|
||||
|
|
@ -2840,7 +2940,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
<div class="stat-label">Address</div>
|
||||
<div class="stat-value" style="font-size:13px;font-family:monospace">${this.shortenAddress(this.address)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ""}
|
||||
|
||||
${this.renderViewTabs()}
|
||||
|
||||
|
|
@ -2848,50 +2948,115 @@ class FolkWalletViewer extends HTMLElement {
|
|||
? this.renderBalanceTable() + this.renderPaymentActions()
|
||||
: this.activeView === "yield"
|
||||
? this.renderYieldTab()
|
||||
: `<div class="viz-container" id="viz-container">
|
||||
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
|
||||
</div>
|
||||
${this.renderTransactionTables()}`
|
||||
: `<div class="viz-wrapper">
|
||||
<button class="details-btn" data-action="open-details">Details</button>
|
||||
<div class="viz-container" id="viz-container">
|
||||
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
|
||||
</div>
|
||||
</div>`
|
||||
}`;
|
||||
}
|
||||
|
||||
private renderCompactAddressBar(): string {
|
||||
const typeLabel = this.walletType === "safe" ? "Safe Multisig" : this.walletType === "eoa" ? "EOA" : "";
|
||||
const isWatched = this.watchedAddresses.some(w => w.address.toLowerCase() === this.address.toLowerCase());
|
||||
return `
|
||||
<div class="compact-address-bar">
|
||||
<span class="compact-addr-chip">
|
||||
<span class="compact-addr-text">${this.shortenAddress(this.address)}</span>
|
||||
${typeLabel ? `<span class="wallet-badge ${this.walletType}" style="margin-left:6px;font-size:10px;padding:2px 6px">${typeLabel}</span>` : ""}
|
||||
</span>
|
||||
${!isWatched ? `<button class="compact-btn" data-action="save-wallet" title="Save to watchlist">+ Save</button>` : ""}
|
||||
<button class="compact-btn" data-action="change-wallet">Change</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderWatchlistChips(): string {
|
||||
const chips = this.watchedAddresses.map(w => {
|
||||
const isActive = this.address && w.address.toLowerCase() === this.address.toLowerCase();
|
||||
return `<button class="watchlist-chip ${isActive ? "active" : ""}" data-watch-address="${w.address}" title="${w.address}">
|
||||
<span class="chip-label">${this.esc(w.label || w.address.slice(0, 8) + '...')}</span>
|
||||
<span class="chip-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span>
|
||||
<span class="chip-remove" data-watch-key="${w.chain}:${w.address.toLowerCase()}">×</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
// Show example wallets as suggestions when watchlist is empty and no wallet loaded
|
||||
const showSuggestions = this.watchedAddresses.length === 0 && !this.hasData() && !this.loading;
|
||||
const suggestions = showSuggestions ? EXAMPLE_WALLETS.map(w => `
|
||||
<button class="watchlist-chip suggested" data-address="${w.address}" title="${w.name}">
|
||||
<span class="chip-label">${w.name}</span>
|
||||
<span class="chip-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span>
|
||||
</button>
|
||||
`).join('') : '';
|
||||
|
||||
if (!chips && !suggestions) {
|
||||
if (!this.hasData()) return `<div class="watchlist-bar"><span style="font-size:0.8rem;color:var(--rs-text-muted)">Save wallets to quickly switch between them</span></div>`;
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="watchlist-bar">
|
||||
${chips}${suggestions}
|
||||
<button class="watchlist-chip add-chip" data-action="add-watch" title="Watch a new address">+</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderDetailsModal(): string {
|
||||
if (!this.detailsModalOpen) return '';
|
||||
return `
|
||||
<div class="details-modal-overlay" data-action="close-details">
|
||||
<div class="details-modal" data-stop-propagation>
|
||||
<div class="details-modal-header">
|
||||
<h3>Wallet Details</h3>
|
||||
<button data-action="close-details">×</button>
|
||||
</div>
|
||||
<div class="details-modal-body">
|
||||
${this.renderBalanceTable()}
|
||||
${this.renderTransactionTables()}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderVisualizerTab(): string {
|
||||
// Yield view is standalone — skip wallet UI entirely
|
||||
if (this.activeView === "yield") {
|
||||
return `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`;
|
||||
}
|
||||
|
||||
const hasData = this.hasData();
|
||||
const showFullAddressBar = !hasData || this.addressBarExpanded;
|
||||
|
||||
return `
|
||||
${this.renderHero()}
|
||||
|
||||
<form class="address-bar" id="address-form">
|
||||
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
|
||||
value="${this.address}" spellcheck="false">
|
||||
<button type="submit">Load</button>
|
||||
</form>
|
||||
${showFullAddressBar ? `
|
||||
<form class="address-bar" id="address-form">
|
||||
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
|
||||
value="${this.address}" spellcheck="false">
|
||||
<button type="submit">Load</button>
|
||||
</form>
|
||||
|
||||
<div class="controls-row">
|
||||
<div class="testnet-toggle ${this.includeTestnets ? "active" : ""}" id="testnet-toggle">
|
||||
<div class="toggle-track"><div class="toggle-thumb"></div></div>
|
||||
<span>Include testnets</span>
|
||||
</div>
|
||||
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
|
||||
${this.walletType ? `
|
||||
<div class="wallet-badge ${this.walletType}">
|
||||
${this.walletType === "safe" ? "⛓ Safe Multisig" : "👤 EOA Wallet"}
|
||||
<div class="controls-row">
|
||||
<div class="testnet-toggle ${this.includeTestnets ? "active" : ""}" id="testnet-toggle">
|
||||
<div class="toggle-track"><div class="toggle-thumb"></div></div>
|
||||
<span>Include testnets</span>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
|
||||
</div>
|
||||
` : this.renderCompactAddressBar()}
|
||||
|
||||
${this.renderWatchlistChips()}
|
||||
|
||||
${this.renderSupportedChains()}
|
||||
|
||||
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
||||
${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
|
||||
|
||||
${this.renderFeatures()}
|
||||
${this.renderExamples()}
|
||||
${this.renderWatchlist()}
|
||||
${!hasData && !this.loading && this.watchedAddresses.length === 0 ? this.renderFeatures() : ""}
|
||||
${this.renderDashboard()}
|
||||
${this.renderDetailsModal()}
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -2918,19 +3083,66 @@ class FolkWalletViewer extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll(".example-item").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
const addr = (item as HTMLElement).dataset.address!;
|
||||
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
||||
if (input) input.value = addr;
|
||||
// Compact address bar actions
|
||||
this.shadow.querySelector('[data-action="change-wallet"]')?.addEventListener('click', () => {
|
||||
this.addressBarExpanded = true;
|
||||
this.render();
|
||||
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
||||
if (input) { input.value = this.address; input.focus(); input.select(); }
|
||||
});
|
||||
|
||||
this.shadow.querySelector('[data-action="save-wallet"]')?.addEventListener('click', () => {
|
||||
const label = prompt('Label (optional):') || '';
|
||||
const chain = this.selectedChain || '1';
|
||||
this.addToWatchlist(this.address, chain, label);
|
||||
});
|
||||
|
||||
// Watchlist chips: click to load address
|
||||
this.shadow.querySelectorAll<HTMLElement>('.watchlist-chip[data-watch-address]').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
// Don't navigate if clicking the remove button
|
||||
if ((e.target as HTMLElement).classList.contains('chip-remove')) return;
|
||||
const addr = el.dataset.watchAddress;
|
||||
if (!addr) return;
|
||||
this.address = addr;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("address", addr);
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
url.searchParams.set('address', addr);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
this.detectChains();
|
||||
});
|
||||
});
|
||||
|
||||
// Watchlist chips: suggested examples
|
||||
this.shadow.querySelectorAll<HTMLElement>('.watchlist-chip.suggested[data-address]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const addr = el.dataset.address;
|
||||
if (!addr) return;
|
||||
this.address = addr;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('address', addr);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
this.detectChains();
|
||||
});
|
||||
});
|
||||
|
||||
// Watchlist: remove chip
|
||||
this.shadow.querySelectorAll<HTMLElement>('.chip-remove').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const key = btn.dataset.watchKey;
|
||||
if (key) this.removeFromWatchlist(key);
|
||||
});
|
||||
});
|
||||
|
||||
// Watchlist: add
|
||||
this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => {
|
||||
const addr = prompt('Wallet address to watch:');
|
||||
if (!addr) return;
|
||||
const label = prompt('Label (optional):') || '';
|
||||
const chain = this.selectedChain || '1';
|
||||
this.addToWatchlist(addr, chain, label);
|
||||
});
|
||||
|
||||
// View tab listeners (skip tour button which has no data-view)
|
||||
this.shadow.querySelectorAll(".view-tab[data-view]").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
|
|
@ -2941,6 +3153,20 @@ class FolkWalletViewer extends HTMLElement {
|
|||
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Details modal
|
||||
this.shadow.querySelector('[data-action="open-details"]')?.addEventListener('click', () => {
|
||||
this.detailsModalOpen = true;
|
||||
this.render();
|
||||
});
|
||||
this.shadow.querySelectorAll('[data-action="close-details"]').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
// Don't close when clicking inside the modal body
|
||||
if ((e.target as HTMLElement).closest('[data-stop-propagation]') && !(e.target as HTMLElement).hasAttribute('data-action')) return;
|
||||
this.detailsModalOpen = false;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Sandbox listeners
|
||||
this.shadow.querySelector("[data-sandbox-toggle]")?.addEventListener("click", () => {
|
||||
this.sandboxActive = !this.sandboxActive;
|
||||
|
|
@ -2980,39 +3206,6 @@ class FolkWalletViewer extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// Watchlist: add
|
||||
this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => {
|
||||
const addr = prompt('Wallet address to watch:');
|
||||
if (!addr) return;
|
||||
const label = prompt('Label (optional):') || '';
|
||||
const chain = this.selectedChain || '1';
|
||||
this.addToWatchlist(addr, chain, label);
|
||||
});
|
||||
|
||||
// Watchlist: click to load
|
||||
this.shadow.querySelectorAll<HTMLElement>('.watch-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const addr = el.dataset.watchAddress;
|
||||
if (!addr) return;
|
||||
const input = this.shadow.querySelector('#address-input') as HTMLInputElement;
|
||||
if (input) input.value = addr;
|
||||
this.address = addr;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('address', addr);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
this.detectChains();
|
||||
});
|
||||
});
|
||||
|
||||
// Watchlist: remove
|
||||
this.shadow.querySelectorAll<HTMLElement>('.watch-remove').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const key = btn.dataset.watchKey;
|
||||
if (key) this.removeFromWatchlist(key);
|
||||
});
|
||||
});
|
||||
|
||||
// Draw visualization if active
|
||||
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
|
|
|
|||
|
|
@ -134,8 +134,20 @@ const STABLECOINS = new Set([
|
|||
"GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD",
|
||||
]);
|
||||
|
||||
// Approximate USD prices for major non-stablecoin tokens (updated periodically)
|
||||
const NATIVE_APPROX_USD: Record<string, number> = {
|
||||
ETH: 2500, WETH: 2500, stETH: 2500, cbETH: 2500, rETH: 2800, wstETH: 2900,
|
||||
MATIC: 0.40, POL: 0.40, WMATIC: 0.40,
|
||||
BNB: 600, WBNB: 600,
|
||||
AVAX: 35, WAVAX: 35,
|
||||
xDAI: 1, WXDAI: 1,
|
||||
CELO: 0.50, GNO: 250,
|
||||
};
|
||||
|
||||
export function estimateUSD(value: number, symbol: string): number | null {
|
||||
if (STABLECOINS.has(symbol)) return value;
|
||||
const price = NATIVE_APPROX_USD[symbol];
|
||||
if (price !== undefined) return value * price;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +184,7 @@ export function transformToTimelineData(
|
|||
type: "in",
|
||||
amount: value,
|
||||
token: symbol,
|
||||
usd: usd !== null ? usd : value,
|
||||
usd: usd !== null ? usd : 0,
|
||||
hasUsdEstimate: usd !== null,
|
||||
chain: chainName,
|
||||
chainId,
|
||||
|
|
@ -239,7 +251,7 @@ export function transformToTimelineData(
|
|||
}
|
||||
|
||||
for (const t of txTransfers) {
|
||||
const usd = t.usd !== null ? t.usd : t.value;
|
||||
const usd = t.usd !== null ? t.usd : 0;
|
||||
timeline.push({
|
||||
date: tx.executionDate,
|
||||
type: "out",
|
||||
|
|
@ -445,7 +457,7 @@ export function transformToMultichainData(
|
|||
if (value <= 0) continue;
|
||||
|
||||
const usd = estimateUSD(value, symbol);
|
||||
const usdVal = usd !== null ? usd : value;
|
||||
const usdVal = usd !== null ? usd : 0;
|
||||
chainTransfers++;
|
||||
chainInflow += usdVal;
|
||||
if (transfer.from) {
|
||||
|
|
@ -546,7 +558,7 @@ export function transformToMultichainData(
|
|||
|
||||
for (const t of outTransfers) {
|
||||
const usd = estimateUSD(t.value, t.symbol);
|
||||
const usdVal = usd !== null ? usd : t.value;
|
||||
const usdVal = usd !== null ? usd : 0;
|
||||
chainOutflow += usdVal;
|
||||
if (t.to) {
|
||||
chainAddresses.add(t.to.toLowerCase());
|
||||
|
|
|
|||
|
|
@ -400,20 +400,15 @@ export function renderTimeline(
|
|||
.on("end", () => svg.style("cursor", "grab"));
|
||||
|
||||
svg.on("wheel", function(event: any) {
|
||||
if (!event.ctrlKey) return; // let normal scroll pass through to page
|
||||
event.preventDefault();
|
||||
const currentTransform = d3.zoomTransform(svg.node());
|
||||
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);
|
||||
svg.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);
|
||||
svg.call(zoom.transform, newTransform);
|
||||
}
|
||||
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);
|
||||
svg.call(zoom.transform, newTransform);
|
||||
}, { passive: false });
|
||||
|
||||
svg.call(zoom);
|
||||
|
|
@ -537,9 +532,10 @@ export function renderFlowChart(
|
|||
.attr("fill", COLORS.red).attr("font-size", "9px").text(`-${flow.value.toLocaleString()} ${flow.token}`);
|
||||
});
|
||||
|
||||
// Zoom
|
||||
// Zoom (only intercept Ctrl+wheel / pinch-to-zoom, let normal scroll pass through)
|
||||
const flowZoom = d3.zoom()
|
||||
.scaleExtent([0.3, 5])
|
||||
.filter((event: any) => event.type !== "wheel" || event.ctrlKey)
|
||||
.on("zoom", (event: any) => g.attr("transform", event.transform))
|
||||
.on("start", () => svg.style("cursor", "grabbing"))
|
||||
.on("end", () => svg.style("cursor", "grab"));
|
||||
|
|
@ -641,9 +637,10 @@ export function renderSankey(
|
|||
.style("font-weight", (d: any) => d.type === "wallet" ? "bold" : "normal")
|
||||
.style("fill", "#e0e0e0");
|
||||
|
||||
// Zoom
|
||||
// Zoom (only intercept Ctrl+wheel / pinch-to-zoom, let normal scroll pass through)
|
||||
const sankeyZoom = d3.zoom()
|
||||
.scaleExtent([0.3, 5])
|
||||
.filter((event: any) => event.type !== "wheel" || event.ctrlKey)
|
||||
.on("zoom", (event: any) => zoomGroup.attr("transform", event.transform))
|
||||
.on("start", () => svg.style("cursor", "grabbing"))
|
||||
.on("end", () => svg.style("cursor", "grab"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue