diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index dc20a4f..20f958a 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -6,6 +6,11 @@ * via EIP-6963 + SIWE. */ +import { transformToTimelineData, transformToSankeyData, transformToMultichainData } from "../lib/data-transform"; +import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform"; +import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; +import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data"; + interface ChainInfo { chainId: string; name: string; @@ -69,6 +74,8 @@ const EXAMPLE_WALLETS = [ { name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" }, ]; +type ViewTab = "balances" | "timeline" | "flow" | "sankey"; + class FolkWalletViewer extends HTMLElement { private shadow: ShadowRoot; private address = ""; @@ -90,6 +97,17 @@ class FolkWalletViewer extends HTMLElement { private linkingInProgress = false; private linkError = ""; + // Visualization state + private activeView: ViewTab = "balances"; + private transfers: Map | null = null; + private transfersLoading = false; + private d3Ready = false; + private vizData: { + timeline?: TimelineEntry[]; + sankey?: SankeyData; + multichain?: MultichainData; + } = {}; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -179,6 +197,12 @@ class FolkWalletViewer extends HTMLElement { { tokenAddress: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", token: { name: "USD Coin", symbol: "USDC", decimals: 6 }, balance: "15750000000", fiatBalance: "15750", fiatConversion: "1" }, { tokenAddress: "0x4f4F9b8D5B4d0Dc10506e5551B0513B61fD59e75", token: { name: "Giveth", symbol: "GIV", decimals: 18 }, balance: "500000000000000000000000", fiatBalance: "2500", fiatConversion: "0.005" }, ]; + // Pre-load demo viz data + this.vizData = { + timeline: DEMO_TIMELINE_DATA, + sankey: DEMO_SANKEY_DATA, + multichain: DEMO_MULTICHAIN_DATA, + }; this.render(); } @@ -201,6 +225,9 @@ class FolkWalletViewer extends HTMLElement { this.detectedChains = []; this.balances = []; this.walletType = ""; + this.activeView = "balances"; + this.transfers = null; + this.vizData = {}; this.render(); try { @@ -263,6 +290,131 @@ class FolkWalletViewer extends HTMLElement { } } + // ── Transfer Loading for Visualizations ── + + private async loadTransfers() { + if (this.isDemo || this.transfers || this.transfersLoading) return; + if (this.walletType !== "safe") return; // Transfer API only for Safes + + this.transfersLoading = true; + this.render(); + + try { + const base = this.getApiBase(); + const chainDataMap = new Map(); + + // Fan out requests to all detected chains + await Promise.allSettled( + this.detectedChains.map(async (ch) => { + try { + const res = await fetch(`${base}/api/safe/${ch.chainId}/${this.address}/transfers?limit=200`); + if (!res.ok) return; + const data = await res.json(); + // Parse results into incoming/outgoing + const incoming: any[] = []; + const outgoing: any[] = []; + const results = data.results || []; + for (const tx of results) { + if (tx.txType === "MULTISIG_TRANSACTION") { + outgoing.push(tx); + } + // Incoming transfers from transfers array inside txs + if (tx.transfers) { + for (const t of tx.transfers) { + if (t.to?.toLowerCase() === this.address.toLowerCase()) { + incoming.push(t); + } + } + } + } + chainDataMap.set(ch.chainId, { incoming, outgoing, chainId: ch.chainId }); + } catch {} + }), + ); + + this.transfers = chainDataMap; + this.computeVizData(); + } catch { + this.transfers = new Map(); + } + + this.transfersLoading = false; + this.render(); + if (this.activeView !== "balances") { + requestAnimationFrame(() => this.drawActiveVisualization()); + } + } + + private computeVizData() { + if (!this.transfers) return; + + this.vizData.timeline = transformToTimelineData(this.transfers, this.address, CHAIN_NAMES); + + // Sankey for selected chain + if (this.selectedChain && this.transfers.has(this.selectedChain)) { + this.vizData.sankey = transformToSankeyData( + this.transfers.get(this.selectedChain), + this.address, + this.selectedChain, + ); + } + + this.vizData.multichain = transformToMultichainData(this.transfers, this.address, CHAIN_NAMES); + } + + private async drawActiveVisualization() { + const container = this.shadow.querySelector("#viz-container") as HTMLElement; + if (!container) return; + + // Lazy-load D3 + if (!this.d3Ready) { + container.innerHTML = '
Loading visualization library...
'; + try { + await loadD3(); + this.d3Ready = true; + } catch { + container.innerHTML = '
Failed to load D3 visualization library.
'; + return; + } + } + + // Build chain color map for flow chart + const chainColorMap: Record = {}; + for (const ch of this.detectedChains) { + chainColorMap[ch.name.toLowerCase()] = ch.color; + } + + switch (this.activeView) { + case "timeline": + if (this.vizData.timeline && this.vizData.timeline.length > 0) { + renderTimeline(container, this.vizData.timeline, { chainColors: chainColorMap }); + } else { + container.innerHTML = '
No timeline data available. Transfer data may still be loading.
'; + } + break; + + case "flow": + if (this.vizData.multichain) { + const mc = this.vizData.multichain; + renderFlowChart(container, mc.flowData["all"] || [], mc.chainStats["all"], { + chainColors: chainColorMap, + safeAddress: this.address, + }); + } else { + container.innerHTML = '
No flow data available.
'; + } + break; + + case "sankey": + if (this.vizData.sankey && this.vizData.sankey.links.length > 0) { + renderSankey(container, this.vizData.sankey); + } else { + container.innerHTML = '
No Sankey data available for the selected chain.
'; + } + break; + } + } + private formatBalance(balance: string, decimals: number): string { const val = Number(balance) / Math.pow(10, decimals); if (val >= 1000000) return `${(val / 1000000).toFixed(2)}M`; @@ -305,8 +457,34 @@ class FolkWalletViewer extends HTMLElement { this.loading = true; this.render(); await this.loadBalances(); + // Recompute sankey for new chain + if (this.transfers && this.transfers.has(chainId)) { + this.vizData.sankey = transformToSankeyData( + this.transfers.get(chainId), + this.address, + chainId, + ); + } this.loading = false; this.render(); + if (this.activeView !== "balances") { + requestAnimationFrame(() => this.drawActiveVisualization()); + } + } + + private handleViewTabClick(view: ViewTab) { + if (this.activeView === view) return; + this.activeView = view; + + if (view !== "balances" && !this.transfers && !this.isDemo) { + this.loadTransfers(); + } + + this.render(); + + if (view !== "balances") { + requestAnimationFrame(() => this.drawActiveVisualization()); + } } private hasData(): boolean { @@ -714,6 +892,24 @@ class FolkWalletViewer extends HTMLElement { .stat-label { font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase; margin-bottom: 6px; } .stat-value { font-size: 20px; font-weight: 700; color: #00d4ff; } + /* ── View Tabs ── */ + .view-tabs { + display: flex; gap: 4px; margin-bottom: 20px; + border-bottom: 2px solid var(--rs-border-subtle); padding-bottom: 0; + } + .view-tab { + padding: 10px 18px; border: none; background: transparent; + color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; font-weight: 500; + border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; + } + .view-tab:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); } + .view-tab.active { + color: #00d4ff; border-bottom-color: #00d4ff; + } + + /* ── Viz container ── */ + .viz-container { min-height: 200px; } + /* ── Dashboard: balance table ── */ .balance-table { width: 100%; border-collapse: collapse; } .balance-table th { @@ -753,6 +949,7 @@ class FolkWalletViewer extends HTMLElement { .address-bar input { min-width: 0; } .chains { flex-wrap: wrap; } .features { grid-template-columns: 1fr 1fr; } + .view-tabs { overflow-x: auto; } } @media (max-width: 480px) { .features { grid-template-columns: 1fr; } @@ -896,6 +1093,55 @@ class FolkWalletViewer extends HTMLElement { `; } + private renderViewTabs(): string { + if (!this.hasData()) return ""; + const tabs: { id: ViewTab; label: string }[] = [ + { id: "balances", label: "Balances" }, + { id: "timeline", label: "Timeline" }, + { id: "flow", label: "Flow Map" }, + { id: "sankey", label: "Sankey" }, + ]; + // Only show viz tabs for Safe wallets (or demo) + const showViz = this.walletType === "safe" || this.isDemo; + const visibleTabs = showViz ? tabs : [tabs[0]]; + + return ` +
+ ${visibleTabs.map(t => ` + + `).join("")} +
`; + } + + private renderBalanceTable(): string { + return this.balances.length > 0 ? ` + + + + + + ${this.balances + .filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) + .sort((a, b) => { + const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0"); + if (fiatDiff !== 0) return fiatDiff; + return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0")); + }) + .map((b) => ` + + + + + + `).join("")} + +
TokenBalanceUSD Value
+ ${this.esc(b.token?.symbol || "ETH")} + ${this.esc(b.token?.name || "Ether")} + ${this.formatBalance(b.balance, b.token?.decimals || 18)}${this.formatUSD(b.fiatBalance)}
+ ` : '
No token balances found on this chain.
'; + } + private renderDashboard(): string { if (!this.hasData()) return ""; const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); @@ -930,32 +1176,14 @@ class FolkWalletViewer extends HTMLElement { - ${this.balances.length > 0 ? ` - - - - - - ${this.balances - .filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) - .sort((a, b) => { - const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0"); - if (fiatDiff !== 0) return fiatDiff; - return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0")); - }) - .map((b) => ` - - - - - - `).join("")} - -
TokenBalanceUSD Value
- ${this.esc(b.token?.symbol || "ETH")} - ${this.esc(b.token?.name || "Ether")} - ${this.formatBalance(b.balance, b.token?.decimals || 18)}${this.formatUSD(b.fiatBalance)}
- ` : '
No token balances found on this chain.
'}`; + ${this.renderViewTabs()} + + ${this.activeView === "balances" + ? this.renderBalanceTable() + : `
+ ${this.transfersLoading ? '
Loading transfer data...
' : ""} +
` + }`; } private render() { @@ -1024,6 +1252,14 @@ class FolkWalletViewer extends HTMLElement { }); }); + // View tab listeners + this.shadow.querySelectorAll(".view-tab").forEach((tab) => { + tab.addEventListener("click", () => { + const view = (tab as HTMLElement).dataset.view as ViewTab; + this.handleViewTabClick(view); + }); + }); + // Linked wallet event listeners this.shadow.querySelector("#link-wallet-btn")?.addEventListener("click", () => { this.startProviderDiscovery(); @@ -1059,6 +1295,11 @@ class FolkWalletViewer extends HTMLElement { } }); }); + + // Draw visualization if active + if (this.activeView !== "balances" && this.hasData()) { + requestAnimationFrame(() => this.drawActiveVisualization()); + } } private esc(s: string): string { diff --git a/modules/rwallet/lib/data-transform.ts b/modules/rwallet/lib/data-transform.ts new file mode 100644 index 0000000..643cc41 --- /dev/null +++ b/modules/rwallet/lib/data-transform.ts @@ -0,0 +1,589 @@ +/** + * Data Transform Module for rWallet + * Converts Safe Global API responses into formats for D3 visualizations. + * TypeScript port of rwallet-online/js/data-transform.js + */ + +// ── Interfaces ── + +export interface TimelineEntry { + date: Date; + type: "in" | "out"; + amount: number; + token: string; + usd: number; + hasUsdEstimate: boolean; + chain: string; + chainId: string; + from?: string; + fromFull?: string; + to?: string; + toFull?: string; +} + +export interface SankeyNode { + name: string; + type: "wallet" | "source" | "target"; + address: string; +} + +export interface SankeyLink { + source: number; + target: number; + value: number; + token: string; +} + +export interface SankeyData { + nodes: SankeyNode[]; + links: SankeyLink[]; +} + +export interface ChainStats { + transfers: number; + inflow: string; + outflow: string; + addresses: string; + period: string; +} + +export interface TransferRecord { + chainId: string; + chainName: string; + date: string; + from?: string; + fromShort?: string; + to?: string; + toShort?: string; + token: string; + amount: number; + usd: number; +} + +export interface MultichainData { + chainStats: Record; + flowData: Record; + allTransfers: { incoming: TransferRecord[]; outgoing: TransferRecord[] }; +} + +export interface FlowEntry { + from: string; + to: string; + value: number; + token: string; + chain: string; +} + +// ── Helpers ── + +export function shortenAddress(addr: string): string { + if (!addr || addr.length < 10) return addr || "Unknown"; + return addr.slice(0, 6) + "..." + addr.slice(-4); +} + +const EXPLORER_URLS: Record = { + "1": "https://etherscan.io", + "10": "https://optimistic.etherscan.io", + "100": "https://gnosisscan.io", + "137": "https://polygonscan.com", + "8453": "https://basescan.org", + "42161": "https://arbiscan.io", + "42220": "https://celoscan.io", + "43114": "https://snowtrace.io", + "56": "https://bscscan.com", + "324": "https://explorer.zksync.io", +}; + +export function explorerLink(address: string, chainId: string): string { + const base = EXPLORER_URLS[chainId]; + if (!base) return "#"; + return `${base}/address/${address}`; +} + +export function txExplorerLink(txHash: string, chainId: string): string { + const base = EXPLORER_URLS[chainId]; + if (!base) return "#"; + return `${base}/tx/${txHash}`; +} + +export function getTransferValue(transfer: any): number { + if (transfer.type === "ERC20_TRANSFER" || transfer.transferType === "ERC20_TRANSFER") { + const decimals = transfer.tokenInfo?.decimals || transfer.token?.decimals || 18; + const raw = transfer.value || "0"; + return parseFloat(raw) / Math.pow(10, decimals); + } + if (transfer.type === "ETHER_TRANSFER" || transfer.transferType === "ETHER_TRANSFER") { + return parseFloat(transfer.value || "0") / 1e18; + } + return 0; +} + +export function getTokenSymbol(transfer: any): string { + return transfer.tokenInfo?.symbol || transfer.token?.symbol || "ETH"; +} + +function getTokenName(transfer: any): string { + return transfer.tokenInfo?.name || transfer.token?.name || "Native"; +} + +// ── Stablecoin USD estimation ── + +const STABLECOINS = new Set([ + "USDC", "USDT", "DAI", "WXDAI", "BUSD", "TUSD", "USDP", "FRAX", + "LUSD", "GUSD", "sUSD", "USDD", "USDGLO", "USD+", "USDe", "crvUSD", + "GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD", +]); + +export function estimateUSD(value: number, symbol: string): number | null { + if (STABLECOINS.has(symbol)) return value; + return null; +} + +// ── Native token symbols per chain ── + +const CHAIN_NATIVE_SYMBOL: Record = { + "1": "ETH", "10": "ETH", "100": "xDAI", "137": "MATIC", + "8453": "ETH", "42161": "ETH", "42220": "CELO", "43114": "AVAX", + "56": "BNB", "324": "ETH", +}; + +// ── Transform: Timeline Data (for Balance River) ── + +export function transformToTimelineData( + chainDataMap: Map, + safeAddress: string, + chainNames: Record, +): TimelineEntry[] { + const timeline: any[] = []; + + for (const [chainId, data] of chainDataMap) { + const chainName = (chainNames[chainId] || `chain-${chainId}`).toLowerCase(); + + // Incoming transfers + if (data.incoming) { + for (const transfer of data.incoming) { + const value = getTransferValue(transfer); + const symbol = getTokenSymbol(transfer); + if (value <= 0) continue; + + const usd = estimateUSD(value, symbol); + timeline.push({ + date: transfer.executionDate || transfer.blockTimestamp || transfer.timestamp, + type: "in", + amount: value, + token: symbol, + usd: usd !== null ? usd : value, + hasUsdEstimate: usd !== null, + chain: chainName, + chainId, + from: shortenAddress(transfer.from), + fromFull: transfer.from, + }); + } + } + + // Outgoing multisig transactions + if (data.outgoing) { + for (const tx of data.outgoing) { + if (!tx.isExecuted) continue; + + const txTransfers: { to: string; value: number; symbol: string; usd: number | null }[] = []; + + // Check transfers array if available + if (tx.transfers && tx.transfers.length > 0) { + for (const t of tx.transfers) { + if (t.from?.toLowerCase() === safeAddress.toLowerCase()) { + const value = getTransferValue(t); + const symbol = getTokenSymbol(t); + if (value > 0) { + txTransfers.push({ to: t.to, value, symbol, usd: estimateUSD(value, symbol) }); + } + } + } + } + + // Fallback: parse from dataDecoded or direct value + if (txTransfers.length === 0) { + if (tx.value && tx.value !== "0") { + const val = parseFloat(tx.value) / 1e18; + const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; + txTransfers.push({ to: tx.to, value: val, symbol: sym, usd: estimateUSD(val, sym) }); + } + + if (tx.dataDecoded?.method === "transfer") { + const params = tx.dataDecoded.parameters || []; + const to = params.find((p: any) => p.name === "to")?.value; + const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; + const val = parseFloat(rawVal) / 1e18; + txTransfers.push({ to, value: val, symbol: "Token", usd: null }); + } + + if (tx.dataDecoded?.method === "multiSend") { + const txsParam = tx.dataDecoded.parameters?.find((p: any) => p.name === "transactions"); + if (txsParam?.valueDecoded) { + for (const inner of txsParam.valueDecoded) { + if (inner.value && inner.value !== "0") { + const val = parseFloat(inner.value) / 1e18; + const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; + txTransfers.push({ to: inner.to, value: val, symbol: sym, usd: estimateUSD(val, sym) }); + } + if (inner.dataDecoded?.method === "transfer") { + const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; + const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; + const val2 = parseFloat(raw2) / 1e18; + txTransfers.push({ to: to2, value: val2, symbol: "Token", usd: null }); + } + } + } + } + } + + for (const t of txTransfers) { + const usd = t.usd !== null ? t.usd : t.value; + timeline.push({ + date: tx.executionDate, + type: "out", + amount: t.value, + token: t.symbol, + usd, + hasUsdEstimate: t.usd !== null, + chain: chainName, + chainId, + to: shortenAddress(t.to), + toFull: t.to, + }); + } + } + } + } + + return timeline + .filter((t) => t.date) + .map((t) => ({ ...t, date: new Date(t.date) })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); +} + +// ── Transform: Sankey Data (for single-chain flow) ── + +export function transformToSankeyData(chainData: any, safeAddress: string, chainId?: string): SankeyData { + const nodeMap = new Map(); + const nodes: SankeyNode[] = []; + const links: SankeyLink[] = []; + const walletLabel = "Safe Wallet"; + + function getNodeIndex(address: string, type: "wallet" | "source" | "target"): number { + const key = address.toLowerCase() === safeAddress.toLowerCase() + ? "wallet" + : `${type}:${address.toLowerCase()}`; + + if (!nodeMap.has(key)) { + const idx = nodes.length; + nodeMap.set(key, idx); + const label = address.toLowerCase() === safeAddress.toLowerCase() + ? walletLabel + : shortenAddress(address); + nodes.push({ name: label, type, address }); + } + return nodeMap.get(key)!; + } + + // Wallet node always first + getNodeIndex(safeAddress, "wallet"); + + // Aggregate inflows by source address + token + const inflowAgg = new Map(); + if (chainData.incoming) { + for (const transfer of chainData.incoming) { + const value = getTransferValue(transfer); + const symbol = getTokenSymbol(transfer); + if (value <= 0 || !transfer.from) continue; + + const key = `${transfer.from.toLowerCase()}:${symbol}`; + const existing = inflowAgg.get(key) || { from: transfer.from, value: 0, symbol }; + existing.value += value; + inflowAgg.set(key, existing); + } + } + + // Add inflow links + for (const [, agg] of inflowAgg) { + const sourceIdx = getNodeIndex(agg.from, "source"); + const walletIdx = nodeMap.get("wallet")!; + links.push({ source: sourceIdx, target: walletIdx, value: agg.value, token: agg.symbol }); + } + + // Aggregate outflows by target address + token + const outflowAgg = new Map(); + if (chainData.outgoing) { + for (const tx of chainData.outgoing) { + if (!tx.isExecuted) continue; + + if (tx.value && tx.value !== "0" && tx.to) { + const val = parseFloat(tx.value) / 1e18; + const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH"; + const key = `${tx.to.toLowerCase()}:${sym}`; + const existing = outflowAgg.get(key) || { to: tx.to, value: 0, symbol: sym }; + existing.value += val; + outflowAgg.set(key, existing); + } + + if (tx.dataDecoded?.method === "transfer") { + const params = tx.dataDecoded.parameters || []; + const to = params.find((p: any) => p.name === "to")?.value; + const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; + if (to) { + const val = parseFloat(rawVal) / 1e18; + const key = `${to.toLowerCase()}:Token`; + const existing = outflowAgg.get(key) || { to, value: 0, symbol: "Token" }; + existing.value += val; + outflowAgg.set(key, existing); + } + } + + if (tx.dataDecoded?.method === "multiSend") { + const txsParam = tx.dataDecoded.parameters?.find((p: any) => p.name === "transactions"); + if (txsParam?.valueDecoded) { + for (const inner of txsParam.valueDecoded) { + if (inner.value && inner.value !== "0" && inner.to) { + const val = parseFloat(inner.value) / 1e18; + const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH"; + const key = `${inner.to.toLowerCase()}:${sym}`; + const existing = outflowAgg.get(key) || { to: inner.to, value: 0, symbol: sym }; + existing.value += val; + outflowAgg.set(key, existing); + } + if (inner.dataDecoded?.method === "transfer") { + const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; + const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; + if (to2) { + const val2 = parseFloat(raw2) / 1e18; + const key = `${to2.toLowerCase()}:Token`; + const existing = outflowAgg.get(key) || { to: to2, value: 0, symbol: "Token" }; + existing.value += val2; + outflowAgg.set(key, existing); + } + } + } + } + } + } + } + + // Add outflow links + const walletIdx = nodeMap.get("wallet")!; + for (const [, agg] of outflowAgg) { + const targetIdx = getNodeIndex(agg.to, "target"); + links.push({ source: walletIdx, target: targetIdx, value: agg.value, token: agg.symbol }); + } + + // Filter out tiny values (noise) + const maxValue = Math.max(...links.map((l) => l.value), 1); + const threshold = maxValue * 0.001; + const filteredLinks = links.filter((l) => l.value >= threshold); + + return { nodes, links: filteredLinks }; +} + +// ── Transform: Multi-Chain Flow Data ── + +function formatUSDValue(value: number): string { + if (value >= 1000000) return `~$${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) return `~$${Math.round(value / 1000)}K`; + return `~$${Math.round(value)}`; +} + +export function transformToMultichainData( + chainDataMap: Map, + safeAddress: string, + chainNames: Record, +): MultichainData { + const chainStats: Record = {}; + const flowData: Record = {}; + const allTransfers: { incoming: TransferRecord[]; outgoing: TransferRecord[] } = { incoming: [], outgoing: [] }; + let totalTransfers = 0; + let totalInflow = 0; + let totalOutflow = 0; + const allAddresses = new Set(); + let minDate: Date | null = null; + let maxDate: Date | null = null; + + for (const [chainId, data] of chainDataMap) { + const chainName = (chainNames[chainId] || `chain-${chainId}`).toLowerCase(); + let chainTransfers = 0; + let chainInflow = 0; + let chainOutflow = 0; + const chainAddresses = new Set(); + let chainMinDate: Date | null = null; + let chainMaxDate: Date | null = null; + const flows: FlowEntry[] = []; + + // Incoming + const inflowAgg = new Map(); + if (data.incoming) { + for (const transfer of data.incoming) { + const value = getTransferValue(transfer); + const symbol = getTokenSymbol(transfer); + if (value <= 0) continue; + + const usd = estimateUSD(value, symbol); + const usdVal = usd !== null ? usd : value; + chainTransfers++; + chainInflow += usdVal; + if (transfer.from) { + chainAddresses.add(transfer.from.toLowerCase()); + allAddresses.add(transfer.from.toLowerCase()); + } + + const date = transfer.executionDate || transfer.blockTimestamp; + if (date) { + const d = new Date(date); + if (!chainMinDate || d < chainMinDate) chainMinDate = d; + if (!chainMaxDate || d > chainMaxDate) chainMaxDate = d; + } + + const from = transfer.from || "Unknown"; + const key = shortenAddress(from); + const existing = inflowAgg.get(key) || { from: shortenAddress(from), value: 0, token: symbol }; + existing.value += usdVal; + inflowAgg.set(key, existing); + + allTransfers.incoming.push({ + chainId, chainName, + date: date || "", + from: transfer.from, + fromShort: shortenAddress(transfer.from), + token: symbol, + amount: value, + usd: usdVal, + }); + } + } + + for (const [, agg] of inflowAgg) { + flows.push({ from: agg.from, to: "Safe Wallet", value: Math.round(agg.value), token: agg.token, chain: chainName }); + } + + // Outgoing + const outflowAgg = new Map(); + if (data.outgoing) { + for (const tx of data.outgoing) { + if (!tx.isExecuted) continue; + chainTransfers++; + + const date = tx.executionDate; + if (date) { + const d = new Date(date); + if (!chainMinDate || d < chainMinDate) chainMinDate = d; + if (!chainMaxDate || d > chainMaxDate) chainMaxDate = d; + } + + const outTransfers: { to: string; value: number; symbol: string }[] = []; + + if (tx.value && tx.value !== "0" && tx.to) { + const val = parseFloat(tx.value) / 1e18; + const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; + outTransfers.push({ to: tx.to, value: val, symbol: sym }); + } + + if (tx.dataDecoded?.method === "transfer") { + const params = tx.dataDecoded.parameters || []; + const to = params.find((p: any) => p.name === "to")?.value; + const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; + if (to) outTransfers.push({ to, value: parseFloat(rawVal) / 1e18, symbol: "Token" }); + } + + if (tx.dataDecoded?.method === "multiSend") { + const txsParam = tx.dataDecoded.parameters?.find((p: any) => p.name === "transactions"); + if (txsParam?.valueDecoded) { + for (const inner of txsParam.valueDecoded) { + if (inner.value && inner.value !== "0" && inner.to) { + const val = parseFloat(inner.value) / 1e18; + const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; + outTransfers.push({ to: inner.to, value: val, symbol: sym }); + } + if (inner.dataDecoded?.method === "transfer") { + const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; + const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; + if (to2) outTransfers.push({ to: to2, value: parseFloat(raw2) / 1e18, symbol: "Token" }); + } + } + } + } + + for (const t of outTransfers) { + const usd = estimateUSD(t.value, t.symbol); + const usdVal = usd !== null ? usd : t.value; + chainOutflow += usdVal; + if (t.to) { + chainAddresses.add(t.to.toLowerCase()); + allAddresses.add(t.to.toLowerCase()); + } + + const key = shortenAddress(t.to); + const existing = outflowAgg.get(key) || { to: shortenAddress(t.to), value: 0, token: t.symbol }; + existing.value += usdVal; + outflowAgg.set(key, existing); + + allTransfers.outgoing.push({ + chainId, chainName, + date: date || "", + to: t.to, + toShort: shortenAddress(t.to), + token: t.symbol, + amount: t.value, + usd: usdVal, + }); + } + } + } + + for (const [, agg] of outflowAgg) { + flows.push({ from: "Safe Wallet", to: agg.to, value: Math.round(agg.value), token: agg.token, chain: chainName }); + } + + const fmt = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + const period = (chainMinDate && chainMaxDate) + ? `${fmt(chainMinDate)} - ${fmt(chainMaxDate)}` + : "No data"; + + chainStats[chainName] = { + transfers: chainTransfers, + inflow: formatUSDValue(chainInflow), + outflow: formatUSDValue(chainOutflow), + addresses: String(chainAddresses.size), + period, + }; + + flowData[chainName] = flows; + + totalTransfers += chainTransfers; + totalInflow += chainInflow; + totalOutflow += chainOutflow; + if (chainMinDate && (!minDate || chainMinDate < minDate)) minDate = chainMinDate; + if (chainMaxDate && (!maxDate || chainMaxDate > maxDate)) maxDate = chainMaxDate; + } + + // Aggregate "all" stats + const fmtAll = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + chainStats["all"] = { + transfers: totalTransfers, + inflow: formatUSDValue(totalInflow), + outflow: formatUSDValue(totalOutflow), + addresses: String(allAddresses.size), + period: (minDate && maxDate) ? `${fmtAll(minDate)} - ${fmtAll(maxDate)}` : "No data", + }; + + // Aggregate "all" flows: top 15 by value + const allFlows: FlowEntry[] = []; + for (const flows of Object.values(flowData)) { + allFlows.push(...flows); + } + allFlows.sort((a, b) => b.value - a.value); + flowData["all"] = allFlows.slice(0, 15); + + // Sort transfers by date + allTransfers.incoming.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + allTransfers.outgoing.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + return { chainStats, flowData, allTransfers }; +} diff --git a/modules/rwallet/lib/wallet-demo-data.ts b/modules/rwallet/lib/wallet-demo-data.ts new file mode 100644 index 0000000..0d7271d --- /dev/null +++ b/modules/rwallet/lib/wallet-demo-data.ts @@ -0,0 +1,111 @@ +/** + * Mock visualization data for demo mode (TEC Commons Fund). + */ + +import type { TimelineEntry, SankeyData, MultichainData, FlowEntry, TransferRecord } from "./data-transform"; + +// ── Timeline: ~30 entries over ~2 years ── + +function d(y: number, m: number, day: number): Date { + return new Date(y, m - 1, day); +} + +export const DEMO_TIMELINE_DATA: TimelineEntry[] = [ + { date: d(2024, 1, 15), type: "in", amount: 50000, token: "WXDAI", usd: 50000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x1234...abcd", fromFull: "0x1234567890abcdef1234567890abcdef12345678" }, + { date: d(2024, 1, 28), type: "in", amount: 15, token: "WETH", usd: 37500, hasUsdEstimate: false, chain: "gnosis", chainId: "100", from: "0x2345...bcde", fromFull: "0x234567890abcdef1234567890abcdef123456789" }, + { date: d(2024, 2, 10), type: "out", amount: 12000, token: "WXDAI", usd: 12000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x3456...cdef", toFull: "0x34567890abcdef1234567890abcdef1234567890" }, + { date: d(2024, 2, 22), type: "in", amount: 100000, token: "TEC", usd: 1000, hasUsdEstimate: false, chain: "gnosis", chainId: "100", from: "0x4567...def0", fromFull: "0x4567890abcdef1234567890abcdef12345678901" }, + { date: d(2024, 3, 5), type: "out", amount: 8500, token: "USDC", usd: 8500, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x5678...ef01", toFull: "0x567890abcdef1234567890abcdef123456789012" }, + { date: d(2024, 3, 18), type: "in", amount: 25000, token: "WXDAI", usd: 25000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x6789...f012", fromFull: "0x67890abcdef1234567890abcdef1234567890123" }, + { date: d(2024, 4, 2), type: "out", amount: 5000, token: "WXDAI", usd: 5000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x7890...0123", toFull: "0x7890abcdef1234567890abcdef12345678901234" }, + { date: d(2024, 4, 15), type: "in", amount: 3, token: "ETH", usd: 9000, hasUsdEstimate: false, chain: "ethereum", chainId: "1", from: "0x8901...1234", fromFull: "0x890abcdef1234567890abcdef123456789012345" }, + { date: d(2024, 5, 1), type: "out", amount: 15000, token: "WXDAI", usd: 15000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x9012...2345", toFull: "0x90abcdef1234567890abcdef1234567890123456" }, + { date: d(2024, 5, 20), type: "in", amount: 200000, token: "GIV", usd: 1000, hasUsdEstimate: false, chain: "gnosis", chainId: "100", from: "0xa012...3456", fromFull: "0xa0abcdef1234567890abcdef1234567890123456" }, + { date: d(2024, 6, 8), type: "out", amount: 2, token: "WETH", usd: 6000, hasUsdEstimate: false, chain: "gnosis", chainId: "100", to: "0xb123...4567", toFull: "0xb1234567890abcdef1234567890abcdef12345678" }, + { date: d(2024, 6, 25), type: "in", amount: 30000, token: "WXDAI", usd: 30000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0xc234...5678", fromFull: "0xc234567890abcdef1234567890abcdef123456789" }, + { date: d(2024, 7, 10), type: "out", amount: 20000, token: "WXDAI", usd: 20000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xd345...6789", toFull: "0xd34567890abcdef1234567890abcdef1234567890" }, + { date: d(2024, 7, 28), type: "in", amount: 10000, token: "USDC", usd: 10000, hasUsdEstimate: true, chain: "optimism", chainId: "10", from: "0xe456...7890", fromFull: "0xe4567890abcdef1234567890abcdef12345678901" }, + { date: d(2024, 8, 14), type: "out", amount: 7500, token: "USDC", usd: 7500, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xf567...8901", toFull: "0xf567890abcdef1234567890abcdef123456789012" }, + { date: d(2024, 9, 1), type: "in", amount: 45000, token: "WXDAI", usd: 45000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x1234...abcd", fromFull: "0x1234567890abcdef1234567890abcdef12345678" }, + { date: d(2024, 9, 18), type: "out", amount: 10000, token: "WXDAI", usd: 10000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x3456...cdef", toFull: "0x34567890abcdef1234567890abcdef1234567890" }, + { date: d(2024, 10, 5), type: "in", amount: 5, token: "ETH", usd: 12500, hasUsdEstimate: false, chain: "ethereum", chainId: "1", from: "0x8901...1234", fromFull: "0x890abcdef1234567890abcdef123456789012345" }, + { date: d(2024, 10, 22), type: "out", amount: 18000, token: "WXDAI", usd: 18000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x5678...ef01", toFull: "0x567890abcdef1234567890abcdef123456789012" }, + { date: d(2024, 11, 8), type: "in", amount: 20000, token: "DAI", usd: 20000, hasUsdEstimate: true, chain: "ethereum", chainId: "1", from: "0x2345...bcde", fromFull: "0x234567890abcdef1234567890abcdef123456789" }, + { date: d(2024, 11, 25), type: "out", amount: 6000, token: "WXDAI", usd: 6000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x7890...0123", toFull: "0x7890abcdef1234567890abcdef12345678901234" }, + { date: d(2024, 12, 10), type: "in", amount: 35000, token: "WXDAI", usd: 35000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0xc234...5678", fromFull: "0xc234567890abcdef1234567890abcdef123456789" }, + { date: d(2024, 12, 28), type: "out", amount: 22000, token: "WXDAI", usd: 22000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xd345...6789", toFull: "0xd34567890abcdef1234567890abcdef1234567890" }, + { date: d(2025, 1, 12), type: "in", amount: 15000, token: "USDC", usd: 15000, hasUsdEstimate: true, chain: "base", chainId: "8453", from: "0xe456...7890", fromFull: "0xe4567890abcdef1234567890abcdef12345678901" }, + { date: d(2025, 1, 30), type: "out", amount: 9000, token: "WXDAI", usd: 9000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0x9012...2345", toFull: "0x90abcdef1234567890abcdef1234567890123456" }, + { date: d(2025, 2, 14), type: "in", amount: 40000, token: "WXDAI", usd: 40000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", from: "0x6789...f012", fromFull: "0x67890abcdef1234567890abcdef1234567890123" }, + { date: d(2025, 2, 28), type: "out", amount: 14000, token: "WXDAI", usd: 14000, hasUsdEstimate: true, chain: "gnosis", chainId: "100", to: "0xf567...8901", toFull: "0xf567890abcdef1234567890abcdef123456789012" }, + { date: d(2025, 3, 5), type: "in", amount: 8000, token: "USDC", usd: 8000, hasUsdEstimate: true, chain: "arbitrum", chainId: "42161", from: "0xa012...3456", fromFull: "0xa0abcdef1234567890abcdef1234567890123456" }, +]; + +// ── Sankey: mock DAO fund distribution ── + +export const DEMO_SANKEY_DATA: SankeyData = { + nodes: [ + { name: "Gitcoin Grants", type: "source", address: "0x1234567890abcdef1234567890abcdef12345678" }, + { name: "Token Bonding", type: "source", address: "0x234567890abcdef1234567890abcdef123456789" }, + { name: "Community Donations", type: "source", address: "0x34567890abcdef1234567890abcdef1234567890" }, + { name: "Safe Wallet", type: "wallet", address: "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1" }, + { name: "Dev Fund", type: "target", address: "0x4567890abcdef1234567890abcdef12345678901" }, + { name: "Research Grants", type: "target", address: "0x567890abcdef1234567890abcdef123456789012" }, + { name: "Operations", type: "target", address: "0x67890abcdef1234567890abcdef1234567890123" }, + { name: "Community Events", type: "target", address: "0x7890abcdef1234567890abcdef12345678901234" }, + ], + links: [ + { source: 0, target: 3, value: 85000, token: "WXDAI" }, + { source: 1, target: 3, value: 120000, token: "TEC" }, + { source: 2, target: 3, value: 45000, token: "WXDAI" }, + { source: 3, target: 4, value: 75000, token: "WXDAI" }, + { source: 3, target: 5, value: 55000, token: "WXDAI" }, + { source: 3, target: 6, value: 30000, token: "WXDAI" }, + { source: 3, target: 7, value: 15000, token: "WXDAI" }, + ], +}; + +// ── Multi-chain: mock stats + flows ── + +const demoFlowsGnosis: FlowEntry[] = [ + { from: "0x1234...abcd", to: "Safe Wallet", value: 85000, token: "WXDAI", chain: "gnosis" }, + { from: "0x2345...bcde", to: "Safe Wallet", value: 37500, token: "WETH", chain: "gnosis" }, + { from: "0x6789...f012", to: "Safe Wallet", value: 65000, token: "WXDAI", chain: "gnosis" }, + { from: "Safe Wallet", to: "0x3456...cdef", value: 22000, token: "WXDAI", chain: "gnosis" }, + { from: "Safe Wallet", to: "0x5678...ef01", value: 26500, token: "USDC", chain: "gnosis" }, + { from: "Safe Wallet", to: "0xd345...6789", value: 42000, token: "WXDAI", chain: "gnosis" }, +]; + +const demoFlowsEthereum: FlowEntry[] = [ + { from: "0x8901...1234", to: "Safe Wallet", value: 21500, token: "ETH", chain: "ethereum" }, + { from: "0x2345...bcde", to: "Safe Wallet", value: 20000, token: "DAI", chain: "ethereum" }, +]; + +export const DEMO_MULTICHAIN_DATA: MultichainData = { + chainStats: { + all: { transfers: 28, inflow: "~$330K", outflow: "~$153K", addresses: "14", period: "Jan 2024 - Mar 2025" }, + gnosis: { transfers: 20, inflow: "~$247K", outflow: "~$120K", addresses: "10", period: "Jan 2024 - Feb 2025" }, + ethereum: { transfers: 5, inflow: "~$59K", outflow: "~$0", addresses: "3", period: "Apr 2024 - Nov 2024" }, + optimism: { transfers: 1, inflow: "~$10K", outflow: "~$0", addresses: "1", period: "Jul 2024" }, + base: { transfers: 1, inflow: "~$15K", outflow: "~$0", addresses: "1", period: "Jan 2025" }, + arbitrum: { transfers: 1, inflow: "~$8K", outflow: "~$0", addresses: "1", period: "Mar 2025" }, + }, + flowData: { + all: [...demoFlowsGnosis, ...demoFlowsEthereum].sort((a, b) => b.value - a.value).slice(0, 15), + gnosis: demoFlowsGnosis, + ethereum: demoFlowsEthereum, + optimism: [{ from: "0xe456...7890", to: "Safe Wallet", value: 10000, token: "USDC", chain: "optimism" }], + base: [{ from: "0xe456...7890", to: "Safe Wallet", value: 15000, token: "USDC", chain: "base" }], + arbitrum: [{ from: "0xa012...3456", to: "Safe Wallet", value: 8000, token: "USDC", chain: "arbitrum" }], + }, + allTransfers: { + incoming: DEMO_TIMELINE_DATA.filter(t => t.type === "in").map(t => ({ + chainId: t.chainId, chainName: t.chain, date: t.date.toISOString(), + from: t.fromFull || "", fromShort: t.from || "", token: t.token, amount: t.amount, usd: t.usd, + })) as TransferRecord[], + outgoing: DEMO_TIMELINE_DATA.filter(t => t.type === "out").map(t => ({ + chainId: t.chainId, chainName: t.chain, date: t.date.toISOString(), + to: t.toFull || "", toShort: t.to || "", token: t.token, amount: t.amount, usd: t.usd, + })) as TransferRecord[], + }, +}; diff --git a/modules/rwallet/lib/wallet-viz.ts b/modules/rwallet/lib/wallet-viz.ts new file mode 100644 index 0000000..9763822 --- /dev/null +++ b/modules/rwallet/lib/wallet-viz.ts @@ -0,0 +1,628 @@ +/** + * D3 rendering functions for rWallet visualizations. + * Ported from rwallet-online standalone HTML pages. + * + * All render functions take DOM elements directly (not CSS selectors) + * for shadow DOM compatibility. + */ + +import type { TimelineEntry, SankeyData, FlowEntry, ChainStats } from "./data-transform"; + +declare const d3: any; + +// ── CDN loader (lazy, same pattern as rmaps loadMapLibre) ── + +let d3Loaded = false; +let d3LoadPromise: Promise | null = null; + +export function loadD3(): Promise { + if (d3Loaded) return Promise.resolve(); + if (d3LoadPromise) return d3LoadPromise; + + d3LoadPromise = new Promise((resolve, reject) => { + const d3Script = document.createElement("script"); + d3Script.src = "https://d3js.org/d3.v7.min.js"; + d3Script.onload = () => { + // Load d3-sankey after d3 core + const sankeyScript = document.createElement("script"); + sankeyScript.src = "https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"; + sankeyScript.onload = () => { + d3Loaded = true; + resolve(); + }; + sankeyScript.onerror = () => reject(new Error("Failed to load d3-sankey")); + document.head.appendChild(sankeyScript); + }; + d3Script.onerror = () => reject(new Error("Failed to load D3")); + document.head.appendChild(d3Script); + }); + + return d3LoadPromise; +} + +// ── Color constants (matching wallet theme) ── + +const COLORS = { + cyan: "#00d4ff", + green: "#4ade80", + red: "#f87171", + pink: "#f472b6", + teal: "#0891b2", + darkTeal: "#0e7490", +}; + +// ── Unique gradient IDs (scoped per render to avoid shadow DOM conflicts) ── + +let vizIdCounter = 0; +function nextVizId(): string { + return `wv${++vizIdCounter}`; +} + +// ── Timeline (Balance River) ── + +export interface TimelineOptions { + width?: number; + height?: number; + chainColors?: Record; +} + +export function renderTimeline( + container: HTMLElement, + data: TimelineEntry[], + options: TimelineOptions = {}, +): void { + container.innerHTML = ""; + + if (data.length === 0) { + container.innerHTML = '

No transaction data available.

'; + return; + } + + const id = nextVizId(); + const margin = { top: 80, right: 50, bottom: 80, left: 60 }; + const width = (options.width || container.clientWidth || 1200) - margin.left - margin.right; + const height = (options.height || 500) - margin.top - margin.bottom; + const centerY = height / 2; + + // Create tooltip inside container (shadow DOM safe) + const tooltip = document.createElement("div"); + Object.assign(tooltip.style, { + position: "absolute", background: "rgba(10,10,20,0.98)", border: "1px solid rgba(255,255,255,0.2)", + borderRadius: "10px", padding: "14px 18px", fontSize: "0.85rem", pointerEvents: "none", + zIndex: "1000", maxWidth: "320px", boxShadow: "0 8px 32px rgba(0,0,0,0.6)", display: "none", + color: "#e0e0e0", + }); + container.style.position = "relative"; + container.appendChild(tooltip); + + // Stats + let totalIn = 0, totalOut = 0, balance = 0, peak = 0; + data.forEach(tx => { + if (tx.type === "in") { totalIn += tx.usd; balance += tx.usd; } + else { totalOut += tx.usd; balance -= tx.usd; } + if (balance > peak) peak = balance; + }); + + // Stats row + const statsRow = document.createElement("div"); + statsRow.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:16px;"; + const statItems = [ + { label: "Total Inflow", value: `$${Math.round(totalIn).toLocaleString()}`, color: COLORS.green }, + { label: "Total Outflow", value: `$${Math.round(totalOut).toLocaleString()}`, color: COLORS.red }, + { label: "Net Change", value: `$${Math.round(totalIn - totalOut).toLocaleString()}`, color: COLORS.cyan }, + { label: "Peak Balance", value: `$${Math.round(peak).toLocaleString()}`, color: COLORS.cyan }, + { label: "Transactions", value: String(data.length), color: "#e0e0e0" }, + ]; + statsRow.innerHTML = statItems.map(s => ` +
+
${s.label}
+
${s.value}
+
+ `).join(""); + container.appendChild(statsRow); + + // Legend + const legend = document.createElement("div"); + legend.style.cssText = "display:flex;justify-content:center;gap:24px;margin-bottom:12px;font-size:0.8rem;"; + legend.innerHTML = ` + Inflows + Balance + Outflows + `; + container.appendChild(legend); + + // SVG container + const svgContainer = document.createElement("div"); + svgContainer.style.cssText = "overflow-x:auto;border-radius:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));background:var(--rs-bg-surface,rgba(255,255,255,0.02));padding:8px;"; + container.appendChild(svgContainer); + + const svg = d3.select(svgContainer) + .append("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .style("cursor", "grab"); + + const mainGroup = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`); + + // Defs (scoped IDs) + const defs = mainGroup.append("defs"); + + const inflowGrad = defs.append("linearGradient").attr("id", `${id}-inflow`).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%"); + inflowGrad.append("stop").attr("offset", "0%").attr("stop-color", COLORS.green).attr("stop-opacity", 0.4); + inflowGrad.append("stop").attr("offset", "40%").attr("stop-color", COLORS.green).attr("stop-opacity", 0.85); + inflowGrad.append("stop").attr("offset", "70%").attr("stop-color", "#22d3ee").attr("stop-opacity", 0.9); + inflowGrad.append("stop").attr("offset", "100%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 1); + + const outflowGrad = defs.append("linearGradient").attr("id", `${id}-outflow`).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%"); + outflowGrad.append("stop").attr("offset", "0%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 1); + outflowGrad.append("stop").attr("offset", "30%").attr("stop-color", COLORS.pink).attr("stop-opacity", 0.9); + outflowGrad.append("stop").attr("offset", "60%").attr("stop-color", COLORS.red).attr("stop-opacity", 0.85); + outflowGrad.append("stop").attr("offset", "100%").attr("stop-color", COLORS.red).attr("stop-opacity", 0.4); + + const riverGrad = defs.append("linearGradient").attr("id", `${id}-river`).attr("x1", "0%").attr("y1", "0%").attr("x2", "0%").attr("y2", "100%"); + riverGrad.append("stop").attr("offset", "0%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 0.9); + riverGrad.append("stop").attr("offset", "30%").attr("stop-color", COLORS.teal).attr("stop-opacity", 1); + riverGrad.append("stop").attr("offset", "50%").attr("stop-color", COLORS.darkTeal).attr("stop-opacity", 1); + riverGrad.append("stop").attr("offset", "70%").attr("stop-color", COLORS.teal).attr("stop-opacity", 1); + riverGrad.append("stop").attr("offset", "100%").attr("stop-color", COLORS.cyan).attr("stop-opacity", 0.9); + + defs.append("clipPath").attr("id", `${id}-clip`).append("rect") + .attr("x", 0).attr("y", -margin.top).attr("width", width).attr("height", height + margin.top + margin.bottom); + + // Scales + const timeExtent = d3.extent(data, (d: TimelineEntry) => d.date) as [Date, Date]; + const timePadding = (timeExtent[1].getTime() - timeExtent[0].getTime()) * 0.05; + const xScale = d3.scaleTime() + .domain([new Date(timeExtent[0].getTime() - timePadding), new Date(timeExtent[1].getTime() + timePadding)]) + .range([0, width]); + + // Balance data + const balanceData: { date: Date; balance: number }[] = []; + let runBal = 0; + balanceData.push({ date: new Date(timeExtent[0].getTime() - timePadding), balance: 0 }); + data.forEach(tx => { + if (tx.type === "in") runBal += tx.usd; + else runBal -= tx.usd; + balanceData.push({ date: tx.date, balance: Math.max(0, runBal) }); + }); + balanceData.push({ date: new Date(timeExtent[1].getTime() + timePadding), balance: Math.max(0, runBal) }); + + const maxBalance = d3.max(balanceData, (d: any) => d.balance) || 1; + const balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([8, 120]); + + const contentGroup = mainGroup.append("g").attr("clip-path", `url(#${id}-clip)`); + const xAxisGroup = mainGroup.append("g").attr("transform", `translate(0,${height + 20})`); + + function updateAxis(scale: any) { + const domain = scale.domain(); + const days = (domain[1] - domain[0]) / (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: any) { + 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: any) => scale(d.date)).y0((d: any) => centerY + balanceScale(d.balance) / 2 + 15).y1((d: any) => centerY - balanceScale(d.balance) / 2 - 15).curve(smoothCurve)); + + // Main river + contentGroup.append("path").datum(balanceData).attr("fill", `url(#${id}-river)`) + .attr("d", d3.area().x((d: any) => scale(d.date)).y0((d: any) => centerY + balanceScale(d.balance) / 2).y1((d: any) => 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: any) => scale(d.date)).y((d: any) => 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: any) => scale(d.date)).y((d: any) => centerY + balanceScale(d.balance) / 2).curve(smoothCurve)); + + // Diagonal waterfall flows + const flowHeight = 80; + const xOffset = flowHeight * 0.7; + let prevBalance = 0; + + data.forEach(tx => { + const x = scale(tx.date); + const balanceBefore = Math.max(0, prevBalance); + if (tx.type === "in") prevBalance += tx.usd; + else prevBalance -= tx.usd; + const balanceAfter = Math.max(0, prevBalance); + + const relevantBalance = tx.type === "in" ? balanceAfter : balanceBefore; + const riverW = balanceScale(relevantBalance); + const proportion = relevantBalance > 0 ? Math.min(1, tx.usd / relevantBalance) : 0.5; + const riverEndHalf = Math.max(4, (proportion * riverW) / 2); + const farEndHalf = Math.max(2, riverEndHalf * 0.3); + + const riverTopAfter = centerY - balanceScale(balanceAfter) / 2; + const riverBottomBefore = centerY + balanceScale(balanceBefore) / 2; + + if (tx.type === "in") { + const endY = riverTopAfter; + const startY = endY - flowHeight; + const startX = x - xOffset; + const endX = x; + + const path = d3.path(); + path.moveTo(startX - farEndHalf, startY); + path.bezierCurveTo(startX - farEndHalf, startY + flowHeight * 0.55, endX - riverEndHalf, endY - flowHeight * 0.45, endX - riverEndHalf, endY); + path.lineTo(endX + riverEndHalf, endY); + path.bezierCurveTo(endX + riverEndHalf, endY - flowHeight * 0.45, startX + farEndHalf, startY + flowHeight * 0.55, startX + farEndHalf, startY); + path.closePath(); + + contentGroup.append("path").attr("d", path.toString()).attr("fill", `url(#${id}-inflow)`) + .attr("opacity", 0.85).style("cursor", "pointer").style("transition", "opacity 0.2s, filter 0.2s") + .on("mouseover", (event: any) => showTxTooltip(event, tx, prevBalance)) + .on("mousemove", (event: any) => moveTip(event)) + .on("mouseout", () => { tooltip.style.display = "none"; }); + } else { + const startY = riverBottomBefore; + const endY = startY + flowHeight; + const startX = x; + const endX = x + xOffset; + + const path = d3.path(); + path.moveTo(startX - riverEndHalf, startY); + path.bezierCurveTo(startX - riverEndHalf, startY + flowHeight * 0.45, endX - farEndHalf, endY - flowHeight * 0.55, endX - farEndHalf, endY); + path.lineTo(endX + farEndHalf, endY); + path.bezierCurveTo(endX + farEndHalf, endY - flowHeight * 0.55, startX + riverEndHalf, startY + flowHeight * 0.45, startX + riverEndHalf, startY); + path.closePath(); + + contentGroup.append("path").attr("d", path.toString()).attr("fill", `url(#${id}-outflow)`) + .attr("opacity", 0.85).style("cursor", "pointer").style("transition", "opacity 0.2s, filter 0.2s") + .on("mouseover", (event: any) => showTxTooltip(event, tx, prevBalance)) + .on("mousemove", (event: any) => moveTip(event)) + .on("mouseout", () => { tooltip.style.display = "none"; }); + } + }); + + // River hover + const riverHoverGroup = contentGroup.append("g"); + riverHoverGroup.append("rect") + .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").style("cursor", "crosshair") + .on("mousemove", function(event: any) { + const [mouseX] = d3.pointer(event); + const hoveredDate = scale.invert(mouseX); + let balAtPoint = 0; + for (const tx of data) { + if (tx.date <= hoveredDate) { + if (tx.type === "in") balAtPoint += tx.usd; + else balAtPoint -= tx.usd; + } else break; + } + riverHoverGroup.selectAll(".bal-ind").remove(); + const t = balanceScale(Math.max(0, balAtPoint)); + riverHoverGroup.append("line").attr("class", "bal-ind") + .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).style("pointer-events", "none"); + riverHoverGroup.append("circle").attr("class", "bal-ind") + .attr("cx", mouseX).attr("cy", centerY).attr("r", 5) + .attr("fill", COLORS.cyan).attr("stroke", "#fff").attr("stroke-width", 2).style("pointer-events", "none"); + + tooltip.innerHTML = ` +
${hoveredDate.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" })}
+ $${Math.round(Math.max(0, balAtPoint)).toLocaleString()} +
Balance at this point
+ `; + tooltip.style.display = "block"; + moveTip(event); + }) + .on("mouseout", function() { + riverHoverGroup.selectAll(".bal-ind").remove(); + tooltip.style.display = "none"; + }); + + // Labels + mainGroup.selectAll(".viz-label").remove(); + mainGroup.append("text").attr("class", "viz-label").attr("x", 30).attr("y", -50).attr("text-anchor", "start") + .attr("fill", COLORS.green).attr("font-size", "13px").attr("font-weight", "bold").attr("opacity", 0.8).text("INFLOWS"); + mainGroup.append("text").attr("class", "viz-label").attr("x", width - 30).attr("y", height + 55).attr("text-anchor", "end") + .attr("fill", COLORS.red).attr("font-size", "13px").attr("font-weight", "bold").attr("opacity", 0.8).text("OUTFLOWS"); + } + + function showTxTooltip(event: any, tx: TimelineEntry, balAfter: number) { + const chain = tx.chain || ""; + tooltip.innerHTML = ` +
${tx.date.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" })}
+ ${chain.charAt(0).toUpperCase() + chain.slice(1)} + + ${tx.type === "in" ? "+" : "-"}$${Math.round(tx.usd).toLocaleString()} + +
${tx.amount.toLocaleString(undefined, { maximumFractionDigits: 4 })} ${tx.token}
+
+ ${tx.type === "in" ? "From: " + (tx.from || "Unknown") : "To: " + (tx.to || "Unknown")} +
+
+ Balance after: $${Math.round(Math.max(0, balAfter)).toLocaleString()} +
+ `; + tooltip.style.display = "block"; + moveTip(event); + } + + function moveTip(event: any) { + const rect = container.getBoundingClientRect(); + let x = event.clientX - rect.left + 15; + let y = event.clientY - rect.top - 10; + if (x + 300 > rect.width) x = event.clientX - rect.left - 300 - 15; + if (y + 200 > rect.height) y = event.clientY - rect.top - 200; + tooltip.style.left = x + "px"; + tooltip.style.top = y + "px"; + } + + updateAxis(xScale); + drawContent(xScale); + + // Zoom + const zoom = d3.zoom() + .scaleExtent([0.5, 20]) + .translateExtent([[-width * 2, 0], [width * 3, height]]) + .extent([[0, 0], [width, height]]) + .on("zoom", (event: any) => { + const newXScale = event.transform.rescaleX(xScale); + updateAxis(newXScale); + drawContent(newXScale); + }) + .on("start", () => svg.style("cursor", "grabbing")) + .on("end", () => svg.style("cursor", "grab")); + + svg.on("wheel", function(event: any) { + 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); + } + }, { passive: false }); + + svg.call(zoom); +} + +// ── Flow Chart (Multi-Chain Force-Directed) ── + +export interface FlowChartOptions { + width?: number; + height?: number; + chainColors?: Record; + safeAddress?: string; +} + +export function renderFlowChart( + container: HTMLElement, + flowData: FlowEntry[], + stats: ChainStats | undefined, + options: FlowChartOptions = {}, +): void { + container.innerHTML = ""; + + if (!flowData || flowData.length === 0) { + container.innerHTML = '

No flow data available.

'; + return; + } + + // Stats row + if (stats) { + const statsRow = document.createElement("div"); + statsRow.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:16px;"; + statsRow.innerHTML = [ + { label: "Transfers", value: stats.transfers, color: COLORS.cyan }, + { label: "Inflow", value: stats.inflow, color: COLORS.green }, + { label: "Outflow", value: stats.outflow, color: COLORS.red }, + { label: "Addresses", value: stats.addresses, color: COLORS.cyan }, + { label: "Period", value: stats.period, color: "#e0e0e0" }, + ].map(s => ` +
+
${s.label}
+
${s.value}
+
+ `).join(""); + container.appendChild(statsRow); + } + + const chartDiv = document.createElement("div"); + chartDiv.style.cssText = "border-radius:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));background:var(--rs-bg-surface,rgba(255,255,255,0.02));overflow:hidden;"; + container.appendChild(chartDiv); + + const w = options.width || container.clientWidth || 1000; + const h = options.height || 400; + + const svg = d3.select(chartDiv).append("svg") + .attr("width", "100%").attr("height", h).attr("viewBox", `0 0 ${w} ${h}`) + .style("cursor", "grab"); + + const g = svg.append("g"); + + const inflows = flowData.filter(f => f.to === "Safe Wallet"); + const outflows = flowData.filter(f => f.from === "Safe Wallet"); + + const walletX = w / 2, walletY = h / 2; + + // Central wallet node + g.append("rect").attr("x", walletX - 70).attr("y", walletY - 35).attr("width", 140).attr("height", 70) + .attr("rx", 12).attr("fill", COLORS.cyan).attr("opacity", 0.9); + g.append("text").attr("x", walletX).attr("y", walletY - 8).attr("text-anchor", "middle") + .attr("fill", "#000").attr("font-weight", "bold").attr("font-size", "13px").text("Safe Wallet"); + if (options.safeAddress) { + const short = options.safeAddress.slice(0, 6) + "..." + options.safeAddress.slice(-4); + g.append("text").attr("x", walletX).attr("y", walletY + 12).attr("text-anchor", "middle") + .attr("fill", "#000").attr("font-family", "monospace").attr("font-size", "10px").text(short); + } + + function getFlowColor(chainName: string): string { + const colors: Record = options.chainColors || {}; + return colors[chainName] || COLORS.cyan; + } + + // Inflows (left side) + const inflowSpacing = h / (inflows.length + 1); + inflows.forEach((flow, i) => { + const y = inflowSpacing * (i + 1); + const sourceX = 120; + const color = getFlowColor(flow.chain); + + const path = d3.path(); + path.moveTo(sourceX + 60, y); + path.bezierCurveTo(sourceX + 150, y, walletX - 150, walletY, walletX - 70, walletY); + g.append("path").attr("d", path.toString()).attr("fill", "none") + .attr("stroke", COLORS.green).attr("stroke-width", Math.max(2, Math.log(flow.value + 1) * 1.2)).attr("stroke-opacity", 0.6); + + g.append("rect").attr("x", sourceX - 60).attr("y", y - 14).attr("width", 120).attr("height", 28) + .attr("rx", 6).attr("fill", color).attr("opacity", 0.3).attr("stroke", color); + g.append("text").attr("x", sourceX).attr("y", y + 4).attr("text-anchor", "middle") + .attr("fill", "#e0e0e0").attr("font-family", "monospace").attr("font-size", "10px").text(flow.from); + g.append("text").attr("x", sourceX + 100).attr("y", y - 20) + .attr("fill", COLORS.green).attr("font-size", "9px").text(`+${flow.value.toLocaleString()} ${flow.token}`); + }); + + // Outflows (right side) + const outflowSpacing = h / (outflows.length + 1); + outflows.forEach((flow, i) => { + const y = outflowSpacing * (i + 1); + const targetX = w - 120; + const color = getFlowColor(flow.chain); + + const path = d3.path(); + path.moveTo(walletX + 70, walletY); + path.bezierCurveTo(walletX + 150, walletY, targetX - 150, y, targetX - 60, y); + g.append("path").attr("d", path.toString()).attr("fill", "none") + .attr("stroke", COLORS.red).attr("stroke-width", Math.max(2, Math.log(flow.value + 1) * 1.2)).attr("stroke-opacity", 0.6); + + g.append("rect").attr("x", targetX - 60).attr("y", y - 14).attr("width", 120).attr("height", 28) + .attr("rx", 6).attr("fill", color).attr("opacity", 0.3).attr("stroke", color); + g.append("text").attr("x", targetX).attr("y", y + 4).attr("text-anchor", "middle") + .attr("fill", "#e0e0e0").attr("font-family", "monospace").attr("font-size", "10px").text(flow.to); + g.append("text").attr("x", targetX - 100).attr("y", y - 20) + .attr("fill", COLORS.red).attr("font-size", "9px").text(`-${flow.value.toLocaleString()} ${flow.token}`); + }); + + // Zoom + const flowZoom = d3.zoom() + .scaleExtent([0.3, 5]) + .on("zoom", (event: any) => g.attr("transform", event.transform)) + .on("start", () => svg.style("cursor", "grabbing")) + .on("end", () => svg.style("cursor", "grab")); + svg.call(flowZoom); +} + +// ── Sankey Diagram ── + +export interface SankeyOptions { + width?: number; + height?: number; +} + +export function renderSankey( + container: HTMLElement, + sankeyData: SankeyData, + options: SankeyOptions = {}, +): void { + container.innerHTML = ""; + + if (!sankeyData || sankeyData.links.length === 0) { + container.innerHTML = '

No transaction flow data available.

'; + return; + } + + // Legend + const legend = document.createElement("div"); + legend.style.cssText = "display:flex;justify-content:center;gap:20px;margin-bottom:12px;font-size:0.85rem;"; + legend.innerHTML = ` + Inflow Sources + Wallet + Outflow Targets + `; + container.appendChild(legend); + + const chartDiv = document.createElement("div"); + chartDiv.style.cssText = "border-radius:12px;border:1px solid var(--rs-border-subtle,rgba(255,255,255,0.1));background:var(--rs-bg-surface,rgba(255,255,255,0.02));overflow:hidden;cursor:grab;"; + container.appendChild(chartDiv); + + const width = options.width || 1200; + const height = options.height || Math.max(400, sankeyData.nodes.length * 35); + const margin = { top: 20, right: 200, bottom: 20, left: 200 }; + + const svg = d3.select(chartDiv).append("svg") + .attr("width", "100%").attr("height", height).attr("viewBox", `0 0 ${width} ${height}`) + .style("cursor", "grab"); + + const zoomGroup = svg.append("g"); + + const sankey = d3.sankey() + .nodeWidth(20) + .nodePadding(15) + .extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]); + + const { nodes, links } = sankey({ + nodes: sankeyData.nodes.map((d: any) => Object.assign({}, d)), + links: sankeyData.links.map((d: any) => Object.assign({}, d)), + }); + + // Links + zoomGroup.append("g") + .selectAll("path") + .data(links) + .join("path") + .attr("d", d3.sankeyLinkHorizontal()) + .attr("fill", "none") + .attr("stroke", (d: any) => { + const sourceNode = nodes[d.source.index !== undefined ? d.source.index : d.source]; + return sourceNode?.type === "source" ? COLORS.green : COLORS.red; + }) + .attr("stroke-opacity", 0.4) + .attr("stroke-width", (d: any) => Math.max(1, d.width)) + .style("transition", "stroke-opacity 0.2s") + .on("mouseover", function(this: any) { d3.select(this).attr("stroke-opacity", 0.7); }) + .on("mouseout", function(this: any) { d3.select(this).attr("stroke-opacity", 0.4); }) + .append("title") + .text((d: any) => `${d.source.name} -> ${d.target.name}\n${d.value.toLocaleString()} ${d.token}`); + + // Nodes + const node = zoomGroup.append("g").selectAll("g").data(nodes).join("g"); + + node.append("rect") + .attr("x", (d: any) => d.x0) + .attr("y", (d: any) => d.y0) + .attr("height", (d: any) => d.y1 - d.y0) + .attr("width", (d: any) => d.x1 - d.x0) + .attr("fill", (d: any) => d.type === "wallet" ? COLORS.cyan : d.type === "source" ? COLORS.green : COLORS.red) + .attr("rx", 3); + + node.append("text") + .attr("x", (d: any) => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6) + .attr("y", (d: any) => (d.y1 + d.y0) / 2) + .attr("dy", "0.35em") + .attr("text-anchor", (d: any) => d.x0 < width / 2 ? "end" : "start") + .text((d: any) => d.name) + .style("font-family", "monospace") + .style("font-size", (d: any) => d.type === "wallet" ? "14px" : "11px") + .style("font-weight", (d: any) => d.type === "wallet" ? "bold" : "normal") + .style("fill", "#e0e0e0"); + + // Zoom + const sankeyZoom = d3.zoom() + .scaleExtent([0.3, 5]) + .on("zoom", (event: any) => zoomGroup.attr("transform", event.transform)) + .on("start", () => svg.style("cursor", "grabbing")) + .on("end", () => svg.style("cursor", "grab")); + svg.call(sankeyZoom); +} diff --git a/vite.config.ts b/vite.config.ts index fcd0fc5..7c2abd8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -326,9 +326,16 @@ export default defineConfig({ ); // Build wallet module component + const walletAlias = { + "../lib/data-transform": resolve(__dirname, "modules/rwallet/lib/data-transform.ts"), + "../lib/wallet-viz": resolve(__dirname, "modules/rwallet/lib/wallet-viz.ts"), + "../lib/wallet-demo-data": resolve(__dirname, "modules/rwallet/lib/wallet-demo-data.ts"), + }; + await build({ configFile: false, root: resolve(__dirname, "modules/rwallet/components"), + resolve: { alias: walletAlias }, build: { emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/rwallet"),