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 sandboxDays = 365;
|
||||||
private sandboxComparisons: ProtocolComparison[] = [];
|
private sandboxComparisons: ProtocolComparison[] = [];
|
||||||
|
|
||||||
|
// UX state
|
||||||
|
private addressBarExpanded = false;
|
||||||
|
private detailsModalOpen = false;
|
||||||
|
|
||||||
// Visualization state
|
// Visualization state
|
||||||
private activeView: ViewTab = "balances";
|
private activeView: ViewTab = "balances";
|
||||||
private transfers: Map<string, any> | null = null;
|
private transfers: Map<string, any> | null = null;
|
||||||
|
|
@ -609,7 +613,6 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.allChainBalances = new Map();
|
this.allChainBalances = new Map();
|
||||||
this.chainFilter = null;
|
this.chainFilter = null;
|
||||||
this.walletType = "";
|
this.walletType = "";
|
||||||
this.activeView = "balances";
|
|
||||||
this.transfers = null;
|
this.transfers = null;
|
||||||
this.vizData = {};
|
this.vizData = {};
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -642,6 +645,19 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loading = false;
|
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();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -997,6 +1013,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
||||||
if (input) {
|
if (input) {
|
||||||
this.address = input.value.trim();
|
this.address = input.value.trim();
|
||||||
|
this.addressBarExpanded = false;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("address", this.address);
|
url.searchParams.set("address", this.address);
|
||||||
window.history.replaceState({}, "", url.toString());
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
|
@ -1947,6 +1964,85 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
.sandbox-balance { display: none; }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.hero-title { font-size: 22px; }
|
.hero-title { font-size: 22px; }
|
||||||
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
|
@ -1959,6 +2055,8 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.view-tab { padding: 6px 10px; font-size: 12px; }
|
.view-tab { padding: 6px 10px; font-size: 12px; }
|
||||||
|
.compact-address-bar { flex-wrap: wrap; }
|
||||||
|
.watchlist-bar { gap: 4px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.features { grid-template-columns: 1fr; }
|
.features { grid-template-columns: 1fr; }
|
||||||
|
|
@ -2813,6 +2911,8 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
Local
|
Local
|
||||||
</div>` : "";
|
</div>` : "";
|
||||||
|
|
||||||
|
const isVizView = this.activeView !== "balances" && this.activeView !== "yield";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="chains">
|
<div class="chains">
|
||||||
<div class="chain-btn ${this.chainFilter === null ? "active" : ""}"
|
<div class="chain-btn ${this.chainFilter === null ? "active" : ""}"
|
||||||
|
|
@ -2823,7 +2923,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
${localBtn}
|
${localBtn}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
${!isVizView ? `<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Total Value</div>
|
<div class="stat-label">Total Value</div>
|
||||||
<div class="stat-value">${this.formatUSD(String(totalUSD))}</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-label">Address</div>
|
||||||
<div class="stat-value" style="font-size:13px;font-family:monospace">${this.shortenAddress(this.address)}</div>
|
<div class="stat-value" style="font-size:13px;font-family:monospace">${this.shortenAddress(this.address)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>` : ""}
|
||||||
|
|
||||||
${this.renderViewTabs()}
|
${this.renderViewTabs()}
|
||||||
|
|
||||||
|
|
@ -2848,22 +2948,90 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
? this.renderBalanceTable() + this.renderPaymentActions()
|
? this.renderBalanceTable() + this.renderPaymentActions()
|
||||||
: this.activeView === "yield"
|
: this.activeView === "yield"
|
||||||
? this.renderYieldTab()
|
? this.renderYieldTab()
|
||||||
: `<div class="viz-container" id="viz-container">
|
: `<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>' : ""}
|
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
|
||||||
</div>
|
</div>
|
||||||
${this.renderTransactionTables()}`
|
</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 {
|
private renderVisualizerTab(): string {
|
||||||
// Yield view is standalone — skip wallet UI entirely
|
// Yield view is standalone — skip wallet UI entirely
|
||||||
if (this.activeView === "yield") {
|
if (this.activeView === "yield") {
|
||||||
return `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`;
|
return `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasData = this.hasData();
|
||||||
|
const showFullAddressBar = !hasData || this.addressBarExpanded;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${this.renderHero()}
|
${this.renderHero()}
|
||||||
|
|
||||||
|
${showFullAddressBar ? `
|
||||||
<form class="address-bar" id="address-form">
|
<form class="address-bar" id="address-form">
|
||||||
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
|
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
|
||||||
value="${this.address}" spellcheck="false">
|
value="${this.address}" spellcheck="false">
|
||||||
|
|
@ -2876,22 +3044,19 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
<span>Include testnets</span>
|
<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>
|
<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>
|
|
||||||
` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
|
` : this.renderCompactAddressBar()}
|
||||||
|
|
||||||
|
${this.renderWatchlistChips()}
|
||||||
|
|
||||||
${this.renderSupportedChains()}
|
${this.renderSupportedChains()}
|
||||||
|
|
||||||
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
${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.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
|
||||||
|
|
||||||
${this.renderFeatures()}
|
${!hasData && !this.loading && this.watchedAddresses.length === 0 ? this.renderFeatures() : ""}
|
||||||
${this.renderExamples()}
|
|
||||||
${this.renderWatchlist()}
|
|
||||||
${this.renderDashboard()}
|
${this.renderDashboard()}
|
||||||
|
${this.renderDetailsModal()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2918,19 +3083,66 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.shadow.querySelectorAll(".example-item").forEach((item) => {
|
// Compact address bar actions
|
||||||
item.addEventListener("click", () => {
|
this.shadow.querySelector('[data-action="change-wallet"]')?.addEventListener('click', () => {
|
||||||
const addr = (item as HTMLElement).dataset.address!;
|
this.addressBarExpanded = true;
|
||||||
|
this.render();
|
||||||
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
||||||
if (input) input.value = addr;
|
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;
|
this.address = addr;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("address", addr);
|
url.searchParams.set('address', addr);
|
||||||
window.history.replaceState({}, "", url.toString());
|
window.history.replaceState({}, '', url.toString());
|
||||||
this.detectChains();
|
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)
|
// View tab listeners (skip tour button which has no data-view)
|
||||||
this.shadow.querySelectorAll(".view-tab[data-view]").forEach((tab) => {
|
this.shadow.querySelectorAll(".view-tab[data-view]").forEach((tab) => {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
|
|
@ -2941,6 +3153,20 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
|
|
||||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
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
|
// Sandbox listeners
|
||||||
this.shadow.querySelector("[data-sandbox-toggle]")?.addEventListener("click", () => {
|
this.shadow.querySelector("[data-sandbox-toggle]")?.addEventListener("click", () => {
|
||||||
this.sandboxActive = !this.sandboxActive;
|
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
|
// Draw visualization if active
|
||||||
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
|
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
|
||||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,20 @@ const STABLECOINS = new Set([
|
||||||
"GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD",
|
"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 {
|
export function estimateUSD(value: number, symbol: string): number | null {
|
||||||
if (STABLECOINS.has(symbol)) return value;
|
if (STABLECOINS.has(symbol)) return value;
|
||||||
|
const price = NATIVE_APPROX_USD[symbol];
|
||||||
|
if (price !== undefined) return value * price;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,7 +184,7 @@ export function transformToTimelineData(
|
||||||
type: "in",
|
type: "in",
|
||||||
amount: value,
|
amount: value,
|
||||||
token: symbol,
|
token: symbol,
|
||||||
usd: usd !== null ? usd : value,
|
usd: usd !== null ? usd : 0,
|
||||||
hasUsdEstimate: usd !== null,
|
hasUsdEstimate: usd !== null,
|
||||||
chain: chainName,
|
chain: chainName,
|
||||||
chainId,
|
chainId,
|
||||||
|
|
@ -239,7 +251,7 @@ export function transformToTimelineData(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const t of txTransfers) {
|
for (const t of txTransfers) {
|
||||||
const usd = t.usd !== null ? t.usd : t.value;
|
const usd = t.usd !== null ? t.usd : 0;
|
||||||
timeline.push({
|
timeline.push({
|
||||||
date: tx.executionDate,
|
date: tx.executionDate,
|
||||||
type: "out",
|
type: "out",
|
||||||
|
|
@ -445,7 +457,7 @@ export function transformToMultichainData(
|
||||||
if (value <= 0) continue;
|
if (value <= 0) continue;
|
||||||
|
|
||||||
const usd = estimateUSD(value, symbol);
|
const usd = estimateUSD(value, symbol);
|
||||||
const usdVal = usd !== null ? usd : value;
|
const usdVal = usd !== null ? usd : 0;
|
||||||
chainTransfers++;
|
chainTransfers++;
|
||||||
chainInflow += usdVal;
|
chainInflow += usdVal;
|
||||||
if (transfer.from) {
|
if (transfer.from) {
|
||||||
|
|
@ -546,7 +558,7 @@ export function transformToMultichainData(
|
||||||
|
|
||||||
for (const t of outTransfers) {
|
for (const t of outTransfers) {
|
||||||
const usd = estimateUSD(t.value, t.symbol);
|
const usd = estimateUSD(t.value, t.symbol);
|
||||||
const usdVal = usd !== null ? usd : t.value;
|
const usdVal = usd !== null ? usd : 0;
|
||||||
chainOutflow += usdVal;
|
chainOutflow += usdVal;
|
||||||
if (t.to) {
|
if (t.to) {
|
||||||
chainAddresses.add(t.to.toLowerCase());
|
chainAddresses.add(t.to.toLowerCase());
|
||||||
|
|
|
||||||
|
|
@ -400,20 +400,15 @@ export function renderTimeline(
|
||||||
.on("end", () => svg.style("cursor", "grab"));
|
.on("end", () => svg.style("cursor", "grab"));
|
||||||
|
|
||||||
svg.on("wheel", function(event: any) {
|
svg.on("wheel", function(event: any) {
|
||||||
|
if (!event.ctrlKey) return; // let normal scroll pass through to page
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const currentTransform = d3.zoomTransform(svg.node());
|
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 scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||||
const [mouseX] = d3.pointer(event);
|
const [mouseX] = d3.pointer(event);
|
||||||
const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor));
|
const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor));
|
||||||
const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k);
|
const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k);
|
||||||
const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale);
|
const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale);
|
||||||
svg.call(zoom.transform, newTransform);
|
svg.call(zoom.transform, newTransform);
|
||||||
}
|
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
svg.call(zoom);
|
svg.call(zoom);
|
||||||
|
|
@ -537,9 +532,10 @@ export function renderFlowChart(
|
||||||
.attr("fill", COLORS.red).attr("font-size", "9px").text(`-${flow.value.toLocaleString()} ${flow.token}`);
|
.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()
|
const flowZoom = d3.zoom()
|
||||||
.scaleExtent([0.3, 5])
|
.scaleExtent([0.3, 5])
|
||||||
|
.filter((event: any) => event.type !== "wheel" || event.ctrlKey)
|
||||||
.on("zoom", (event: any) => g.attr("transform", event.transform))
|
.on("zoom", (event: any) => g.attr("transform", event.transform))
|
||||||
.on("start", () => svg.style("cursor", "grabbing"))
|
.on("start", () => svg.style("cursor", "grabbing"))
|
||||||
.on("end", () => svg.style("cursor", "grab"));
|
.on("end", () => svg.style("cursor", "grab"));
|
||||||
|
|
@ -641,9 +637,10 @@ export function renderSankey(
|
||||||
.style("font-weight", (d: any) => d.type === "wallet" ? "bold" : "normal")
|
.style("font-weight", (d: any) => d.type === "wallet" ? "bold" : "normal")
|
||||||
.style("fill", "#e0e0e0");
|
.style("fill", "#e0e0e0");
|
||||||
|
|
||||||
// Zoom
|
// Zoom (only intercept Ctrl+wheel / pinch-to-zoom, let normal scroll pass through)
|
||||||
const sankeyZoom = d3.zoom()
|
const sankeyZoom = d3.zoom()
|
||||||
.scaleExtent([0.3, 5])
|
.scaleExtent([0.3, 5])
|
||||||
|
.filter((event: any) => event.type !== "wheel" || event.ctrlKey)
|
||||||
.on("zoom", (event: any) => zoomGroup.attr("transform", event.transform))
|
.on("zoom", (event: any) => zoomGroup.attr("transform", event.transform))
|
||||||
.on("start", () => svg.style("cursor", "grabbing"))
|
.on("start", () => svg.style("cursor", "grabbing"))
|
||||||
.on("end", () => svg.style("cursor", "grab"));
|
.on("end", () => svg.style("cursor", "grab"));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue